Adopt tuple attribute io and the carrier lowering, deleting the bridge adapters for record-only wires

This commit is contained in:
Dennis Kobert
2026-08-06 18:48:16 +00:00
parent 166241b0e8
commit 9c7a633d5b
14 changed files with 1481 additions and 718 deletions

View File

@@ -263,27 +263,6 @@ macro_rules! tagged_value {
}
}
/// The bridge rows for every wire type a value can carry, spliced
/// while plain and record worlds coexist.
pub fn record_bridge_entries() -> Vec<(core_types::ProtoNodeIdentifier, core_types::registry::RegistryEntry)> {
let mut entries = Vec::new();
entries.extend(core_types::registry::record_bridge_rows::<()>());
entries.extend(core_types::registry::record_bridge_rows::<List<f64>>());
entries.extend(core_types::registry::record_bridge_rows::<List<Color>>());
entries.extend(core_types::registry::record_bridge_rows::<List<GradientStops>>());
entries.extend(core_types::registry::record_bridge_rows::<List<BrushStroke>>());
entries.extend(core_types::registry::record_bridge_rows::<RenderOutput>());
entries.extend(core_types::registry::record_bridge_rows::<List<NodeId>>());
entries.extend(core_types::registry::record_bridge_rows::<DocumentNode>());
entries.extend(core_types::registry::record_bridge_rows::<ContextModification>());
entries.extend(core_types::registry::record_bridge_rows::<Arc<PlatformEditorApi>>());
entries.extend(core_types::registry::record_bridge_rows::<ResourceHash>());
$(
entries.extend(core_types::registry::record_bridge_rows::<$ty>());
)*
entries
}
/// Materializes the value as [`Self::to_dynany`] does, wrapped in a `ClonedNode` edge typed by [`Self::ty`].
pub fn to_edge(self) -> Result<EdgeHandle, String> {
match self {

View File

@@ -374,40 +374,6 @@ impl ProtoNetwork {
nullification_node_id
}
/// Splices the selected `Ref` adapters between the consumer at `consumer_index` and its
/// producers, keeping the node vec topologically sorted and ids stable. `materialized` carries
/// the adapter ids already spliced during this update so shared producers get one adapter.
fn insert_ref_adapters(&mut self, consumer_index: usize, adapters: &[RefAdapter], materialized: &mut HashSet<NodeId>) {
let mut consumer_index = consumer_index;
for adapter in adapters {
let (_, consumer) = &self.nodes[consumer_index];
let producer = consumer.unwrap_construction_nodes()[adapter.input_index];
let mut path = consumer.original_location.path.clone();
// A path extension with a placeholder value which should not conflict with existing paths
if let Some(path) = path.as_mut() {
path.push(NodeId(11));
}
let node = ProtoNode {
construction_args: ConstructionArgs::Nodes(vec![producer]),
call_argument: concrete!(Context),
identifier: adapter.identifier.clone(),
original_location: OriginalLocation { path, ..Default::default() },
..Default::default()
};
let id = node.stable_node_id().expect("adapter nodes always produce a stable id");
if materialized.insert(id) {
self.nodes.insert(consumer_index, (id, node));
consumer_index += 1;
}
let (_, consumer) = &mut self.nodes[consumer_index];
if let ConstructionArgs::Nodes(args) = &mut consumer.construction_args {
args[adapter.input_index] = id;
}
}
}
fn find_context_dependencies(&mut self, id: NodeId) -> (ContextModification, Option<NodeId>) {
let mut branch_dependencies = Vec::new();
let mut combined_deps = ContextModification::default();
@@ -696,39 +662,15 @@ impl TypingContext {
/// Updates the `TypingContext` with a given proto network. This will infer the types of the nodes
/// and store them in the `inferred` field. The proto network has to be topologically sorted
/// and contain fully resolved stable node ids. When a node's inputs disagree with every
/// implementation only on `Ref`-ness, the matching lend/clone_out adapter is spliced into the
/// network and inference resumes with the adapter in place.
/// and contain fully resolved stable node ids.
pub fn update(&mut self, network: &mut ProtoNetwork) -> Result<(), GraphErrors> {
let mut materialized_adapters = HashSet::new();
let mut index = 0;
while index < network.nodes.len() {
let (id, node) = &network.nodes[index];
match self.infer(*id, node) {
Ok(_) => index += 1,
Err(errors) => match self.select_ref_adapters(node) {
Some(adapters) => network.insert_ref_adapters(index, &adapters, &mut materialized_adapters),
None => return Err(errors),
},
}
for (id, node) in &network.nodes {
self.infer(*id, node)?;
}
Ok(())
}
fn select_ref_adapters(&self, node: &ProtoNode) -> Option<Vec<RefAdapter>> {
let ConstructionArgs::Nodes(args) = &node.construction_args else { return None };
let inputs = args.iter().map(|id| self.inferred.get(id).map(NodeIOTypes::ty)).collect::<Option<Vec<_>>>()?;
let candidates: Vec<&NodeIOTypes> = self.lookup.get(&node.identifier)?.iter().map(|entry| &entry.io).collect();
let adapters = select_ref_adapters_over(&node.call_argument, &inputs, &candidates)?;
let constructible = adapters.iter().all(|adapter| {
self.lookup
.get(&adapter.identifier)
.is_some_and(|entries| entries.iter().any(|entry| entry.io.inputs.len() == 1 && valid_type(&inputs[adapter.input_index], &entry.io.inputs[0])))
});
constructible.then_some(adapters)
}
pub fn remove_inference(&mut self, node_id: NodeId) -> Option<NodeIOTypes> {
self.constructor.remove(&node_id);
self.inferred.remove(&node_id)
@@ -916,58 +858,6 @@ fn valid_type(from: &Type, to: &Type) -> bool {
}
}
#[derive(Clone, Debug)]
struct RefAdapter {
input_index: usize,
identifier: ProtoNodeIdentifier,
}
/// Picks the lend or clone_out adapter that reconciles a proposed edge with a wanted edge when they
/// differ only by one `Ref` layer around a concrete output.
fn ref_adapter(proposed: &Type, wanted: &Type) -> Option<ProtoNodeIdentifier> {
let (Type::Fn(_, proposed_output), Type::Fn(_, wanted_output)) = (proposed, wanted) else {
return None;
};
match (proposed_output.as_ref(), wanted_output.as_ref()) {
(Type::Record(inner), wanted_output @ Type::Concrete(_)) if valid_type(inner, wanted_output) => Some(ProtoNodeIdentifier::new("core_types::record::RecordExtractNode")),
(proposed_output @ Type::Concrete(_), Type::Record(inner)) if valid_type(proposed_output, inner) => Some(ProtoNodeIdentifier::new("core_types::record::RecordLiftNode")),
_ => None,
}
}
/// Selects adapters for the single implementation the inputs match modulo `Ref`-ness, or `None`
/// when no or several implementations do.
fn select_ref_adapters_over(call_argument: &Type, inputs: &[Type], candidates: &[&NodeIOTypes]) -> Option<Vec<RefAdapter>> {
let mut selected = None;
for candidate in candidates {
if !valid_type(&candidate.call_argument, call_argument) || candidate.inputs.len() != inputs.len() {
continue;
}
let mut adapters = Vec::new();
let mut fits = true;
for (input_index, (proposed, wanted)) in inputs.iter().zip(&candidate.inputs).enumerate() {
if valid_type(proposed, wanted) {
continue;
}
match ref_adapter(proposed, wanted) {
Some(identifier) => adapters.push(RefAdapter { input_index, identifier }),
None => {
fits = false;
break;
}
}
}
if !fits || adapters.is_empty() {
continue;
}
if selected.is_some() {
return None;
}
selected = Some(adapters);
}
selected
}
/// Returns a list of all generic types used in the node
fn collect_generics(types: &NodeIOTypes) -> Vec<Cow<'static, str>> {
let inputs = [&types.call_argument].into_iter().chain(types.inputs.iter().map(|x| x.nested_type()));
@@ -1292,126 +1182,3 @@ mod test {
}
}
}
#[cfg(test)]
mod adapter_splice_test {
use super::*;
fn adapter_lookup() -> &'static HashMap<ProtoNodeIdentifier, Vec<RegistryEntry>> {
static LOOKUP: std::sync::LazyLock<HashMap<ProtoNodeIdentifier, Vec<RegistryEntry>>> = std::sync::LazyLock::new(|| {
let unused: NodeConstructor = |_| Err(ConstructionError::Arity { expected: 0, got: 0 });
[
(
ProtoNodeIdentifier::new("wants_owned"),
vec![RegistryEntry {
io: NodeIOTypes::new(concrete!(Context), concrete!(String), vec![edge_type::<String>()]),
constructor: unused,
}],
),
(
ProtoNodeIdentifier::new("wants_owned_ambiguously"),
vec![
RegistryEntry {
io: NodeIOTypes::new(concrete!(Context), concrete!(String), vec![edge_type::<String>()]),
constructor: unused,
},
RegistryEntry {
io: NodeIOTypes::new(concrete!(Context), concrete!(f64), vec![edge_type::<String>()]),
constructor: unused,
},
],
),
(
ProtoNodeIdentifier::new("core_types::record::RecordExtractNode"),
vec![RegistryEntry {
io: NodeIOTypes::new(concrete!(Context), concrete!(String), vec![record_edge_type::<String>()]),
constructor: unused,
}],
),
]
.into_iter()
.collect()
});
&LOOKUP
}
fn string_value() -> ProtoNode {
ProtoNode::value(ConstructionArgs::Value(TaggedValue::String("landed".to_string()).into()), vec![])
}
fn consumer(identifier: &str, producer: NodeId) -> ProtoNode {
ProtoNode {
identifier: ProtoNodeIdentifier::with_owned_string(identifier.to_string()),
call_argument: concrete!(Context),
construction_args: ConstructionArgs::Nodes(vec![producer]),
..Default::default()
}
}
#[test]
fn a_record_only_mismatch_splices_the_extract_adapter() {
let mut network = ProtoNetwork {
inputs: vec![],
output: NodeId(1),
nodes: vec![(NodeId(0), string_value()), (NodeId(1), consumer("wants_owned", NodeId(0)))],
};
let mut typing = TypingContext::new(adapter_lookup());
typing.update(&mut network).unwrap();
assert_eq!(network.nodes.len(), 3);
let (adapter_id, adapter) = &network.nodes[1];
assert_eq!(adapter.identifier.as_str(), "core_types::record::RecordExtractNode");
assert_eq!(adapter.unwrap_construction_nodes(), vec![NodeId(0)]);
assert_eq!(network.nodes[2].1.unwrap_construction_nodes(), vec![*adapter_id]);
assert_eq!(typing.type_of(*adapter_id).unwrap().return_value, concrete!(String));
assert_eq!(typing.type_of(NodeId(1)).unwrap().return_value, concrete!(String));
}
#[test]
fn consumers_sharing_a_producer_share_one_adapter() {
let mut network = ProtoNetwork {
inputs: vec![],
output: NodeId(2),
nodes: vec![
(NodeId(0), string_value()),
(NodeId(1), consumer("wants_owned", NodeId(0))),
(NodeId(2), consumer("wants_owned", NodeId(0))),
],
};
let mut typing = TypingContext::new(adapter_lookup());
typing.update(&mut network).unwrap();
assert_eq!(network.nodes.len(), 4);
let adapters: Vec<NodeId> = network
.nodes
.iter()
.filter(|(_, node)| node.identifier.as_str() == "core_types::record::RecordExtractNode")
.map(|(id, _)| *id)
.collect();
let [adapter_id] = adapters.as_slice() else {
panic!("expected exactly one shared adapter, got {adapters:?}");
};
let consumers: Vec<Vec<NodeId>> = network
.nodes
.iter()
.filter(|(_, node)| node.identifier.as_str() == "wants_owned")
.map(|(_, node)| node.unwrap_construction_nodes())
.collect();
assert_eq!(consumers, vec![vec![*adapter_id], vec![*adapter_id]]);
}
#[test]
fn an_ambiguous_record_mismatch_stays_an_error() {
let mut network = ProtoNetwork {
inputs: vec![],
output: NodeId(1),
nodes: vec![(NodeId(0), string_value()), (NodeId(1), consumer("wants_owned_ambiguously", NodeId(0)))],
};
let mut typing = TypingContext::new(adapter_lookup());
assert!(typing.update(&mut network).is_err());
assert_eq!(network.nodes.len(), 2, "an ambiguous mismatch must not materialize adapters");
}
}

View File

@@ -586,11 +586,10 @@ mod test {
let raster_list = TaggedValue::from_type(&core_types::concrete!(graphene_std::list::List<graphene_std::raster_types::Raster<graphene_std::raster_types::CPU>>)).unwrap();
let network = ProtoNetwork {
inputs: vec![],
output: NodeId(2),
output: NodeId(1),
nodes: vec![
(NodeId(0), ProtoNode::value(ConstructionArgs::Value(raster_list.into()), vec![])),
(NodeId(1), proto_node("graphene_core::debug::CloneNode", vec![NodeId(0)])),
(NodeId(2), proto_node("core_types::record::RecordExtractNode", vec![NodeId(1)])),
],
};
@@ -599,8 +598,14 @@ mod test {
let generations = [];
let scope = EvalScope::new(None, None, None, &generations, &arena);
let ctx = ContextImpl::root(&scope);
let result: Option<GPoll<graphene_std::list::List<graphene_std::raster_types::Raster<graphene_std::raster_types::CPU>>>> = executor.tree().eval(NodeId(2), &ctx);
assert!(matches!(result, Some(GPoll::Final(_))), "the flipped clone must evaluate over record wires, got {result:?}");
let edge = executor
.tree()
.get(NodeId(1))
.unwrap()
.downcast_record::<graphene_std::list::List<graphene_std::raster_types::Raster<graphene_std::raster_types::CPU>>>()
.unwrap();
let result = edge.eval(&ctx);
assert!(matches!(result, GPoll::Final(_)), "the flipped clone must evaluate over record wires, got a non-final poll");
}
#[test]
@@ -608,12 +613,11 @@ mod test {
let raster_list = TaggedValue::from_type(&core_types::concrete!(graphene_std::list::List<graphene_std::raster_types::Raster<graphene_std::raster_types::CPU>>)).unwrap();
let network = ProtoNetwork {
inputs: vec![],
output: NodeId(3),
output: NodeId(2),
nodes: vec![
(NodeId(0), ProtoNode::value(ConstructionArgs::Value(raster_list.into()), vec![])),
(NodeId(1), ProtoNode::value(ConstructionArgs::Value(TaggedValue::U32(4).into()), vec![])),
(NodeId(2), proto_node("raster_nodes::image_color_palette::ImageColorPaletteNode", vec![NodeId(0), NodeId(1)])),
(NodeId(3), proto_node("core_types::record::RecordExtractNode", vec![NodeId(2)])),
],
};
@@ -622,42 +626,39 @@ mod test {
let generations = [];
let scope = EvalScope::new(None, None, None, &generations, &arena);
let ctx = ContextImpl::root(&scope);
let result: Option<GPoll<graphene_std::list::List<graphene_std::raster::color::Color>>> = executor.tree().eval(NodeId(3), &ctx);
assert!(matches!(result, Some(GPoll::Final(_))), "the palette must evaluate through its record wires, got {result:?}");
let edge = executor
.tree()
.get(NodeId(2))
.unwrap()
.downcast_record::<graphene_std::list::List<graphene_std::raster::color::Color>>()
.unwrap();
let result = edge.eval(&ctx);
assert!(matches!(result, GPoll::Final(_)), "the palette must evaluate through its record wires, got a non-final poll");
}
#[test]
fn a_record_wire_types_wires_and_evaluates_through_the_registry() {
fn a_value_edge_is_a_record_wire_end_to_end() {
let network = ProtoNetwork {
inputs: vec![],
output: NodeId(2),
nodes: vec![
(NodeId(0), ProtoNode::value(ConstructionArgs::Value(TaggedValue::F64(7.).into()), vec![])),
(NodeId(1), proto_node("core_types::record::RecordLiftNode", vec![NodeId(0)])),
(NodeId(2), proto_node("core_types::record::RecordExtractNode", vec![NodeId(1)])),
],
output: NodeId(0),
nodes: vec![(NodeId(0), ProtoNode::value(ConstructionArgs::Value(TaggedValue::F64(7.).into()), vec![]))],
};
let executor = DynamicExecutor::new(network).unwrap();
let lift = executor.tree().get(NodeId(1)).unwrap();
assert_eq!(lift.ty(), &core_types::registry::record_edge_type::<f64>());
assert!(lift.layout().is_some());
let value = executor.tree().get(NodeId(0)).unwrap();
assert_eq!(value.ty(), &core_types::registry::record_edge_type::<f64>());
assert!(value.layout().is_some());
assert_eq!((&executor).execute(()).unwrap(), GPoll::Final(TaggedValue::F64(7.)));
}
#[test]
fn a_record_monitor_row_forwards_and_introspects_through_the_executor() {
let mut monitor = proto_node("graphene_core::memo::MonitorNode", vec![NodeId(1)]);
let mut monitor = proto_node("graphene_core::memo::MonitorNode", vec![NodeId(0)]);
monitor.original_location.path = Some(vec![NodeId(9)]);
let network = ProtoNetwork {
inputs: vec![],
output: NodeId(3),
nodes: vec![
(NodeId(0), ProtoNode::value(ConstructionArgs::Value(TaggedValue::F64(7.).into()), vec![])),
(NodeId(1), proto_node("core_types::record::RecordLiftNode", vec![NodeId(0)])),
(NodeId(2), monitor),
(NodeId(3), proto_node("core_types::record::RecordExtractNode", vec![NodeId(2)])),
],
output: NodeId(1),
nodes: vec![(NodeId(0), ProtoNode::value(ConstructionArgs::Value(TaggedValue::F64(7.).into()), vec![])), (NodeId(1), monitor)],
};
let executor = DynamicExecutor::new(network).unwrap();
@@ -671,11 +672,10 @@ mod test {
fn a_memoize_row_wires_generically_and_replays_over_record_wires() {
let network = ProtoNetwork {
inputs: vec![],
output: NodeId(2),
output: NodeId(1),
nodes: vec![
(NodeId(0), ProtoNode::value(ConstructionArgs::Value(TaggedValue::String(String::from("cached")).into()), vec![])),
(NodeId(1), proto_node("graphene_core::memo::MemoizeNode", vec![NodeId(0)])),
(NodeId(2), proto_node("core_types::record::RecordExtractNode", vec![NodeId(1)])),
],
};
@@ -694,15 +694,14 @@ mod test {
}
#[test]
fn a_context_modification_row_wires_over_a_plain_value_wire() {
fn a_context_modification_row_wires_over_a_value_wire() {
let network = ProtoNetwork {
inputs: vec![],
output: NodeId(3),
output: NodeId(2),
nodes: vec![
(NodeId(0), ProtoNode::value(ConstructionArgs::Value(TaggedValue::F64(7.).into()), vec![])),
(NodeId(1), modification_value()),
(NodeId(2), proto_node("graphene_core::context_modification::ContextModificationNode", vec![NodeId(0), NodeId(1)])),
(NodeId(3), proto_node("core_types::record::RecordExtractNode", vec![NodeId(2)])),
],
};
@@ -710,51 +709,17 @@ mod test {
assert_eq!((&executor).execute(()).unwrap(), GPoll::Final(TaggedValue::F64(7.)));
}
#[test]
fn a_context_modification_over_a_wire_without_a_lift_row_reports_at_typing() {
let network = ProtoNetwork {
inputs: vec![],
output: NodeId(3),
nodes: vec![
(NodeId(0), ProtoNode::value(ConstructionArgs::Value(TaggedValue::BrushStrokes(vec![]).into()), vec![])),
(NodeId(1), modification_value()),
(NodeId(2), proto_node("graphene_core::context_modification::ContextModificationNode", vec![NodeId(0), NodeId(1)])),
(NodeId(3), proto_node("core_types::record::RecordExtractNode", vec![NodeId(2)])),
],
};
let result = DynamicExecutor::new(network);
let error = format!("{:?}", result.err());
assert!(!error.contains("MissingLayout"), "an absent lift row must be a typing error, not a construction failure: {error}");
}
#[test]
fn nested_context_modifications_forward_the_layout() {
let network = ProtoNetwork {
inputs: vec![],
output: NodeId(5),
output: NodeId(4),
nodes: vec![
(NodeId(0), ProtoNode::value(ConstructionArgs::Value(TaggedValue::F64(7.).into()), vec![])),
(NodeId(1), modification_value()),
(NodeId(2), proto_node("graphene_core::context_modification::ContextModificationNode", vec![NodeId(0), NodeId(1)])),
(NodeId(3), modification_value()),
(NodeId(4), proto_node("graphene_core::context_modification::ContextModificationNode", vec![NodeId(2), NodeId(3)])),
(NodeId(5), proto_node("core_types::record::RecordExtractNode", vec![NodeId(4)])),
],
};
let executor = DynamicExecutor::new(network).unwrap();
assert_eq!((&executor).execute(()).unwrap(), GPoll::Final(TaggedValue::F64(7.)));
}
#[test]
fn a_lift_adapter_is_spliced_between_a_plain_producer_and_a_record_consumer() {
let network = ProtoNetwork {
inputs: vec![],
output: NodeId(1),
nodes: vec![
(NodeId(0), ProtoNode::value(ConstructionArgs::Value(TaggedValue::F64(7.).into()), vec![])),
(NodeId(1), proto_node("core_types::record::RecordExtractNode", vec![NodeId(0)])),
],
};
@@ -766,12 +731,11 @@ mod test {
fn stacked_frame_memos_replay_over_record_wires() {
let network = ProtoNetwork {
inputs: vec![],
output: NodeId(3),
output: NodeId(2),
nodes: vec![
(NodeId(0), string_value("memoized")),
(NodeId(1), proto_node("graphene_core::memo::FrameMemoNode", vec![NodeId(0)])),
(NodeId(2), proto_node("graphene_core::memo::FrameMemoNode", vec![NodeId(1)])),
(NodeId(3), proto_node("core_types::record::RecordExtractNode", vec![NodeId(2)])),
],
};

View File

@@ -17,7 +17,7 @@ use graphene_std::transform::Footprint;
use graphene_std::uuid::NodeId;
use graphene_std::vector::Vector;
use graphene_std::{Artboard, Context, Graphic, ProtoNodeIdentifier, SourceId, concrete, fn_type};
use node_registry_macros::{convert_node, into_node, record_extract_node, record_lift_node};
use node_registry_macros::{convert_node, into_node};
use std::collections::HashMap;
#[cfg(feature = "gpu")]
use wgpu_executor::WgpuExecutorHandle;
@@ -109,98 +109,6 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, Vec<RegistryEntry>> {
// ==========
// MEMO NODES
// ==========
// ============
// REF ADAPTERS
// ============
#[cfg(target_family = "wasm")]
#[cfg(target_family = "wasm")]
#[cfg(target_family = "wasm")]
record_lift_node!(f64),
record_extract_node!(f64),
record_lift_node!(()),
record_extract_node!(()),
record_lift_node!(bool),
record_extract_node!(bool),
record_lift_node!(u32),
record_extract_node!(u32),
record_lift_node!(u64),
record_extract_node!(u64),
record_lift_node!(f32),
record_extract_node!(f32),
record_lift_node!(DVec2),
record_extract_node!(DVec2),
record_lift_node!(IVec2),
record_extract_node!(IVec2),
record_lift_node!(DAffine2),
record_extract_node!(DAffine2),
record_lift_node!(Option<DAffine2>),
record_extract_node!(Option<DAffine2>),
record_lift_node!(Footprint),
record_extract_node!(Footprint),
record_lift_node!(SourceId),
record_extract_node!(SourceId),
record_lift_node!(BlendMode),
record_extract_node!(BlendMode),
record_lift_node!(graphene_std::vector::style::GradientType),
record_extract_node!(graphene_std::vector::style::GradientType),
record_lift_node!(graphene_std::vector::style::GradientSpreadMethod),
record_extract_node!(graphene_std::vector::style::GradientSpreadMethod),
record_lift_node!(String),
record_extract_node!(String),
record_lift_node!(List<String>),
record_extract_node!(List<String>),
record_lift_node!(List<NodeId>),
record_extract_node!(List<NodeId>),
record_lift_node!(List<f64>),
record_extract_node!(List<f64>),
record_lift_node!(List<u8>),
record_extract_node!(List<u8>),
record_lift_node!(List<Vector>),
record_extract_node!(List<Vector>),
record_lift_node!(List<Graphic>),
record_extract_node!(List<Graphic>),
record_lift_node!(List<Raster<CPU>>),
record_extract_node!(List<Raster<CPU>>),
#[cfg(feature = "gpu")]
record_lift_node!(List<Raster<GPU>>),
#[cfg(feature = "gpu")]
record_extract_node!(List<Raster<GPU>>),
record_lift_node!(List<Color>),
record_extract_node!(List<Color>),
record_lift_node!(List<Artboard>),
record_extract_node!(List<Artboard>),
record_lift_node!(List<GradientStops>),
record_extract_node!(List<GradientStops>),
record_lift_node!(AttributeDyn),
record_extract_node!(AttributeDyn),
record_lift_node!(AttributeValueDyn),
record_extract_node!(AttributeValueDyn),
record_lift_node!(ListDyn),
record_extract_node!(ListDyn),
record_lift_node!(std::sync::Arc<PlatformEditorApi>),
record_extract_node!(std::sync::Arc<PlatformEditorApi>),
record_lift_node!(RuntimeHandle),
record_extract_node!(RuntimeHandle),
record_lift_node!(RenderIntermediate),
record_extract_node!(RenderIntermediate),
record_lift_node!(RenderOutput),
record_extract_node!(RenderOutput),
#[cfg(target_family = "wasm")]
record_lift_node!(CanvasHandle),
#[cfg(target_family = "wasm")]
record_extract_node!(CanvasHandle),
#[cfg(feature = "gpu")]
record_lift_node!(WgpuExecutorHandle),
#[cfg(feature = "gpu")]
record_extract_node!(WgpuExecutorHandle),
#[cfg(feature = "gpu")]
record_lift_node!(Option<WgpuExecutorHandle>),
#[cfg(feature = "gpu")]
record_extract_node!(Option<WgpuExecutorHandle>),
#[cfg(feature = "gpu")]
record_lift_node!(wgpu_executor::WgpuPipelineCache),
#[cfg(feature = "gpu")]
record_extract_node!(wgpu_executor::WgpuPipelineCache),
];
// =============
// CONVERT NODES
@@ -228,8 +136,6 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, Vec<RegistryEntry>> {
.flatten(),
);
node_types.extend(graph_craft::document::value::TaggedValue::record_bridge_entries());
node_types.extend(core_types::registry::record_bridge_rows::<graphene_std::application_io::resource::Resource>());
let mut map: HashMap<ProtoNodeIdentifier, Vec<RegistryEntry>> = HashMap::new();
let insert = |map: &mut HashMap<ProtoNodeIdentifier, Vec<RegistryEntry>>, id: ProtoNodeIdentifier, entry: RegistryEntry| {
@@ -273,14 +179,22 @@ mod node_registry_macros {
(
ProtoNodeIdentifier::new(concat!["graphene_core::ops::IntoNode<", stringify!($to), ">"]),
RegistryEntry {
io: NodeIOTypes::new(concrete!(Context), concrete!($to), vec![fn_type!(Context, $from)]),
io: NodeIOTypes::new(
concrete!(Context),
core_types::registry::record_type::<$to>(),
vec![core_types::registry::record_edge_type::<$from>()],
),
constructor: |inputs| {
if inputs.len() != 1 {
return Err(ConstructionError::Arity { expected: 1, got: inputs.len() });
}
let mut inputs = inputs.into_iter();
let node = graphene_std::ops::IntoNode::<$to, _>::new(inputs.next().unwrap().downcast::<$from>()?);
Ok(EdgeHandle::new(std::sync::Arc::new(node) as std::sync::Arc<ErasedNode<$to>>))
let handle = inputs.next().unwrap();
let Some(layout) = handle.layout().cloned() else {
return Err(ConstructionError::MissingLayout);
};
let node = graphene_std::ops::IntoNode::<$to, _, $from>::new(handle.downcast_record::<$from>()?, &layout);
Ok(EdgeHandle::new_record::<$to>(std::sync::Arc::new(node) as std::sync::Arc<core_types::registry::ErasedRecordNode>))
},
},
)
@@ -334,21 +248,41 @@ mod node_registry_macros {
RegistryEntry {
io: NodeIOTypes::new(
concrete!(Context),
concrete!($to),
vec![fn_type!(Context, $from), fn_type!(Context, $convert), fn_type!(Context, RuntimeHandle), fn_type!(Context, SourceId)],
core_types::registry::record_type::<$to>(),
vec![
core_types::registry::record_edge_type::<$from>(),
core_types::registry::record_edge_type::<$convert>(),
core_types::registry::record_edge_type::<RuntimeHandle>(),
core_types::registry::record_edge_type::<SourceId>(),
],
),
constructor: |inputs| {
if inputs.len() != 4 {
return Err(ConstructionError::Arity { expected: 4, got: inputs.len() });
}
let mut inputs = inputs.into_iter();
let node = graphene_std::ops::ConvertAsyncNode::<$to, _, _, _, _>::new(
inputs.next().unwrap().downcast::<$from>()?,
inputs.next().unwrap().downcast::<$convert>()?,
inputs.next().unwrap().downcast::<RuntimeHandle>()?,
inputs.next().unwrap().downcast::<SourceId>()?,
let mut claim = || {
let handle = inputs.next().unwrap();
let Some(layout) = handle.layout().cloned() else {
return Err(ConstructionError::MissingLayout);
};
Ok((handle, layout))
};
let (value, value_layout) = claim()?;
let (converter, converter_layout) = claim()?;
let (runtime, runtime_layout) = claim()?;
let (source, source_layout) = claim()?;
let node = graphene_std::ops::ConvertAsyncNode::<$to, _, _, _, _, $from, $convert>::new(
value.downcast_record::<$from>()?,
converter.downcast_record::<$convert>()?,
runtime.downcast_record::<RuntimeHandle>()?,
source.downcast_record::<SourceId>()?,
&value_layout,
&converter_layout,
&runtime_layout,
&source_layout,
);
Ok(EdgeHandle::new(std::sync::Arc::new(node) as std::sync::Arc<ErasedNode<$to>>))
Ok(EdgeHandle::new_record::<$to>(std::sync::Arc::new(node) as std::sync::Arc<core_types::registry::ErasedRecordNode>))
},
},
)
@@ -357,62 +291,69 @@ mod node_registry_macros {
(
ProtoNodeIdentifier::new(concat!["graphene_core::ops::ConvertNode<", stringify!($to), ">"]),
RegistryEntry {
io: NodeIOTypes::new(concrete!(Context), concrete!($to), vec![fn_type!(Context, $from), fn_type!(Context, $convert)]),
io: NodeIOTypes::new(
concrete!(Context),
core_types::registry::record_type::<$to>(),
vec![core_types::registry::record_edge_type::<$from>(), core_types::registry::record_edge_type::<$convert>()],
),
constructor: |inputs| {
if inputs.len() != 2 {
return Err(ConstructionError::Arity { expected: 2, got: inputs.len() });
}
let mut inputs = inputs.into_iter();
let node = graphene_std::ops::ConvertNode::<$to, _, _>::new(inputs.next().unwrap().downcast::<$from>()?, inputs.next().unwrap().downcast::<$convert>()?);
Ok(EdgeHandle::new(std::sync::Arc::new(node) as std::sync::Arc<ErasedNode<$to>>))
let value = inputs.next().unwrap();
let Some(value_layout) = value.layout().cloned() else {
return Err(ConstructionError::MissingLayout);
};
let converter = inputs.next().unwrap();
let Some(converter_layout) = converter.layout().cloned() else {
return Err(ConstructionError::MissingLayout);
};
let node = graphene_std::ops::ConvertNode::<$to, _, _, $from, $convert>::new(
value.downcast_record::<$from>()?,
converter.downcast_record::<$convert>()?,
&value_layout,
&converter_layout,
);
Ok(EdgeHandle::new_record::<$to>(std::sync::Arc::new(node) as std::sync::Arc<core_types::registry::ErasedRecordNode>))
},
},
)
};
}
macro_rules! record_lift_node {
($type:ty) => {
(
ProtoNodeIdentifier::new("core_types::record::RecordLiftNode"),
RegistryEntry {
io: NodeIOTypes::new(concrete!(Context), core_types::registry::record_type::<$type>(), vec![fn_type!(Context, $type)]),
constructor: |inputs| {
if inputs.len() != 1 {
return Err(ConstructionError::Arity { expected: 1, got: inputs.len() });
}
let mut inputs = inputs.into_iter();
let node = core_types::record::RecordLift::<$type, _>::new(inputs.next().unwrap().downcast::<$type>()?);
Ok(EdgeHandle::new_record::<$type>(std::sync::Arc::new(node) as std::sync::Arc<core_types::registry::ErasedRecordNode>))
},
},
)
};
}
macro_rules! record_extract_node {
($type:ty) => {
(
ProtoNodeIdentifier::new("core_types::record::RecordExtractNode"),
RegistryEntry {
io: NodeIOTypes::new(concrete!(Context), concrete!($type), vec![core_types::registry::record_edge_type::<$type>()]),
constructor: |inputs| {
if inputs.len() != 1 {
return Err(ConstructionError::Arity { expected: 1, got: inputs.len() });
}
let mut inputs = inputs.into_iter();
let edge = inputs.next().unwrap();
let layout = edge.layout().ok_or(ConstructionError::MissingLayout)?.clone();
let node = core_types::record::RecordExtract::<$type, _>::new(edge.downcast_record::<$type>()?, &layout);
Ok(EdgeHandle::new(std::sync::Arc::new(node) as std::sync::Arc<ErasedNode<$type>>))
},
},
)
};
}
pub(crate) use convert_node;
pub(crate) use into_node;
pub(crate) use record_extract_node;
pub(crate) use record_lift_node;
}
#[cfg(test)]
mod tests {
use super::*;
use graphene_std::Type;
fn is_record_edge(ty: &Type) -> bool {
match ty {
Type::Fn(_, output) => matches!(&**output, Type::Record(_)),
_ => false,
}
}
/// One wire kind: every row consumes and produces record wires. A plain
/// io type here would need a bridge adapter, and those are gone.
#[test]
fn every_registry_row_is_record_typed() {
let mut plain: Vec<String> = Vec::new();
for (id, entries) in NODE_REGISTRY.iter() {
for entry in entries {
let plain_output = !matches!(entry.io.return_value, Type::Record(_));
let plain_inputs: Vec<usize> = entry.io.inputs.iter().enumerate().filter(|(_, ty)| !is_record_edge(ty)).map(|(index, _)| index).collect();
if plain_output || !plain_inputs.is_empty() {
plain.push(format!("{} plain_output={plain_output} plain_inputs={plain_inputs:?}", id.as_str()));
}
}
}
plain.sort();
assert!(plain.is_empty(), "plain io remains in the registry:\n{}", plain.join("\n"));
}
}

View File

@@ -14,6 +14,7 @@ use glam::{DAffine2, DVec2};
use std::any::TypeId;
use std::collections::HashMap;
use std::collections::hash_map::Entry;
use std::marker::PhantomData;
use std::ops::Deref;
use std::sync::{LazyLock, Mutex};
@@ -71,6 +72,23 @@ impl<'e, A: Attribute> std::fmt::Debug for Attr<'e, A> {
}
}
/// A deletion of `A` in a node's return tuple: the name leaves the output
/// layout, so downstream reads yield the declared default again. Functionally
/// a write of the default; the value carries nothing.
pub struct RemoveAttr<A: Attribute>(PhantomData<A>);
impl<A: Attribute> RemoveAttr<A> {
pub const fn new() -> Self {
RemoveAttr(PhantomData)
}
}
impl<A: Attribute> Default for RemoveAttr<A> {
fn default() -> Self {
Self::new()
}
}
/// A census row: what is known about one declared attribute name.
#[derive(Clone, Copy, Debug)]
pub struct AttributeInfo {

View File

@@ -186,6 +186,25 @@ impl Layout {
}
}
/// This layout minus the named fields, offsets recomputed. Removing an
/// absent name is a no-op: downstream reads yield the default either way.
pub fn without(&self, removes: &[(&str, u8)]) -> Layout {
let retained: Vec<FieldWrite> = self
.fields
.iter()
.filter(|field| !removes.contains(&(field.name, field.level)))
.map(|field| FieldWrite {
name: field.name,
level: field.level,
size: field.size,
align: field.align,
read_erased: field.read_erased,
repark: field.repark,
})
.collect();
Layout::default().with_writes(self.depth, self.element, &retained)
}
/// The union of several layouts over the same element and depth.
pub fn union(layouts: &[&Layout]) -> Layout {
let first = layouts.first().expect("a union needs at least one layout");
@@ -392,10 +411,18 @@ impl<'a, N> RecordEdgeInput<'a, N> {
/// The raw lazy edge handed to a poll kernel whose wire rides records while
/// the kernel consumes the plain element.
pub struct ElementEdge<'a, El, N> {
/// # Safety
/// `rec` must be a record of the layout the offsets were resolved against
/// and `El` its element type; both are proven at wiring.
unsafe fn element_only<El: Clone>(rec: Rec, _reads: &[Option<usize>]) -> El {
unsafe { read_element::<El>(rec) }
}
pub struct ElementEdge<'a, Out, N> {
node: &'a N,
layout: &'a Layout,
_marker: std::marker::PhantomData<fn() -> El>,
reads: &'a [Option<usize>],
read: unsafe fn(Rec, &[Option<usize>]) -> Out,
}
impl<'a, El: Clone, N> ElementEdge<'a, El, N> {
@@ -403,27 +430,38 @@ impl<'a, El: Clone, N> ElementEdge<'a, El, N> {
Self {
node,
layout,
_marker: std::marker::PhantomData,
reads: &[],
read: element_only::<El>,
}
}
}
pub fn eval<'d, C>(&self, ctx: &C) -> GPoll<El>
impl<'a, Out, N> ElementEdge<'a, Out, N> {
/// `read` must be sound against the layout the offsets in `reads` were
/// resolved from; the macro proves both at wiring.
pub fn with_reads(node: &'a N, layout: &'a Layout, reads: &'a [Option<usize>], read: unsafe fn(Rec, &[Option<usize>]) -> Out) -> Self {
Self { node, layout, reads, read }
}
pub fn eval<'d, C>(&self, ctx: &C) -> GPoll<Out>
where
N: Node<C, Output = RecordValue<'d>>,
{
self.node.eval(ctx).map(|value| unsafe { read_element::<El>(self.layout.rec(&value)) })
self.node.eval(ctx).map(|value| unsafe { (self.read)(self.layout.rec(&value), self.reads) })
}
}
/// The lazy input handed to a kernel whose edge rides a record wire while
/// the kernel consumes the plain element.
/// the kernel consumes the plain element, or the element beside its declared
/// attribute reads.
#[derive(Clone, Copy)]
pub struct ElementLazyInput<'a, El, N> {
pub struct ElementLazyInput<'a, Out, N> {
node: &'a N,
cell: &'a crate::node::StatusCell,
input_index: usize,
layout: &'a Layout,
_marker: std::marker::PhantomData<fn() -> El>,
reads: &'a [Option<usize>],
read: unsafe fn(Rec, &[Option<usize>]) -> Out,
}
impl<'a, El: Clone, N> ElementLazyInput<'a, El, N> {
@@ -433,16 +471,39 @@ impl<'a, El: Clone, N> ElementLazyInput<'a, El, N> {
cell,
input_index,
layout,
_marker: std::marker::PhantomData,
reads: &[],
read: element_only::<El>,
}
}
}
impl<'a, Out, N> ElementLazyInput<'a, Out, N> {
/// `read` must be sound against the layout the offsets in `reads` were
/// resolved from; the macro proves both at wiring.
pub fn with_reads(
node: &'a N,
cell: &'a crate::node::StatusCell,
input_index: usize,
layout: &'a Layout,
reads: &'a [Option<usize>],
read: unsafe fn(Rec, &[Option<usize>]) -> Out,
) -> Self {
Self {
node,
cell,
input_index,
layout,
reads,
read,
}
}
pub fn eval<'d, C>(&self, ctx: &C) -> Result<El, crate::gpoll::Interrupt>
pub fn eval<'d, C>(&self, ctx: &C) -> Result<Out, crate::gpoll::Interrupt>
where
N: Node<C, Output = RecordValue<'d>>,
{
let value = self.cell.eval_input(self.input_index, self.node, ctx)?;
Ok(unsafe { read_element::<El>(self.layout.rec(&value)) })
Ok(unsafe { (self.read)(self.layout.rec(&value), self.reads) })
}
}
@@ -563,8 +624,10 @@ pub mod stack {
/// Field-by-field carry from `from`'s layout into `to`'s, computed at
/// wiring. The element copy is included when `carry_element` holds, which is
/// exactly when the node does not write a concrete element itself.
pub fn copy_plan(from: &Layout, to: &Layout, carry_element: bool) -> Vec<(usize, usize, usize)> {
/// exactly when the node does not write a concrete element itself. `removes`
/// names the fields the node deletes, which are exactly the ones allowed to
/// be absent from `to`.
pub fn copy_plan(from: &Layout, to: &Layout, carry_element: bool, removes: &[(&str, u8)]) -> Vec<(usize, usize, usize)> {
let mut plan = Vec::new();
if carry_element {
assert_eq!(from.element.size, to.element.size, "a carried element must keep its size");
@@ -573,6 +636,9 @@ pub fn copy_plan(from: &Layout, to: &Layout, carry_element: bool) -> Vec<(usize,
}
}
for field in &from.fields {
if removes.contains(&(field.name, field.level)) {
continue;
}
let target = to.offset_of(field.name, field.level).expect("carried field missing from the output layout");
plan.push((field.offset, target, field.size));
}
@@ -586,6 +652,53 @@ pub unsafe fn write_field<T>(dst: *mut u8, offset: usize, value: T) {
unsafe { dst.add(offset).cast::<T>().write(value) }
}
/// Finishes a carried record frame: the element lands beside the fields
/// already carried into `dst`, inline frames copy out of the scratch bytes,
/// and the frame releases in every branch, so the frame lifecycle closes
/// here. Arena exhaustion of a parked element reports as an error poll.
///
/// # Safety
/// `dst` must be the claimed frame (or inline scratch when `frame_bytes` is
/// 0) of a record whose element is `T` and whose frame size is `frame_bytes`,
/// with every carried field already written.
pub unsafe fn lift_poll_into<'e, T: Send + Sync + 'static>(poll: GPoll<T>, dst: *mut u8, frame_bytes: usize, arena: &'e crate::arena::Arena) -> GPoll<RecordValue<'e>> {
let release = || {
if frame_bytes != 0 {
stack::pop(dst);
}
};
let build = |element: T| {
let written = unsafe { write_element(dst, element, arena) };
release();
written.map(|()| match frame_bytes {
0 => unsafe { dst.cast::<RecordValue>().read() },
_ => RecordValue::spilled(unsafe { Rec::new(dst.cast_const()) }),
})
};
let exhausted = || {
GPoll::Error(Box::new(crate::gpoll::GraphError {
kind: crate::gpoll::ErrorKind::ArenaExhausted,
trace: Vec::new(),
}))
};
match poll {
GPoll::Final(element) => build(element).map_or_else(exhausted, GPoll::Final),
GPoll::Partial(element) => build(element).map_or_else(exhausted, GPoll::Partial),
GPoll::Fallback(boxed) => {
let (element, error) = *boxed;
build(element).map_or_else(exhausted, |value| GPoll::Fallback(Box::new((value, error))))
}
GPoll::Pending => {
release();
GPoll::Pending
}
GPoll::Error(error) => {
release();
GPoll::Error(error)
}
}
}
/// Whether elements of `T` move once into the arena and ride as references:
/// records byte-copy their contents and never run drop glue, so a type is
/// byte-carried exactly when it has none.
@@ -637,6 +750,28 @@ pub unsafe fn read_element<T: Clone>(rec: Rec) -> T {
unsafe { borrow_element::<T>(rec) }.clone()
}
/// Borrows a record's element for the rest of the evaluation. Parked elements
/// borrow the arena, inline elements borrow their record value in place, and
/// a byte-carried spilled element copies into the arena first: its stack
/// region dies with the next push, and a borrow taken before the consumer's
/// own frame push always has one coming. `None` reports arena exhaustion.
///
/// # Safety
/// The record's element must be a `T` in the form [`element_parked`] picks,
/// and for inline layouts the record value must outlive the borrow.
pub unsafe fn borrow_or_park<'e, T: Send + Sync + 'static>(rec: Rec, layout: &Layout, arena: &'e crate::arena::Arena) -> Option<&'e T> {
if element_parked::<T>() {
return Some(unsafe { rec.element::<&T>() });
}
if layout.is_inline() {
return Some(unsafe { &*rec.ptr().cast::<T>() });
}
// A bitwise read duplicates soundly: byte-carried elements have no drop
// glue.
let value = unsafe { rec.ptr().cast::<T>().read() };
arena.alloc(value).map(|(parked, _)| parked)
}
/// # Safety
/// `dst` must be fresh element storage of a record whose element is `T`.
/// `None` reports arena exhaustion for a parked element.
@@ -692,7 +827,7 @@ impl SourcePlan {
if source == union {
return None;
}
let moves = copy_plan(source, union, true);
let moves = copy_plan(source, union, true, &[]);
let fills = union
.fields
.iter()
@@ -870,8 +1005,10 @@ impl OwnedRecord {
}
}
/// Lifts a plain producer onto a record wire: the element lands at offset 0
/// of a fresh element-only record, parked when it carries drop glue.
/// Law-test scaffolding: wraps an arbitrary plain node onto a record wire
/// (the element lands at offset 0 of a fresh element-only record, parked when
/// it carries drop glue). No production path constructs one; value edges are
/// [`crate::value::ValueSource`].
pub struct RecordLift<El, N> {
edge: N,
layout: Layout,
@@ -905,8 +1042,9 @@ where
}
}
/// Extracts the element from a record wire for a plain consumer, cloning out
/// of the parked reference when the element carries drop glue.
/// Law-test scaffolding: a plain probe over a record wire, cloning the
/// element out of the parked reference when it carries drop glue. No
/// production path constructs one.
pub struct RecordExtract<El, N> {
edge: N,
layout: Layout,

View File

@@ -267,49 +267,6 @@ pub struct RegistryEntry {
pub constructor: NodeConstructor,
}
/// The bridge rows of `T`: a plain producer onto a record wire and a record
/// wire into a plain consumer. One pair exists per wire type while the
/// deferred plain classes (shader, batch) coexist with record wires.
pub fn record_bridge_rows<T: Clone + Send + Sync + 'static>() -> [(crate::ProtoNodeIdentifier, RegistryEntry); 2] {
[
(crate::ProtoNodeIdentifier::new("core_types::record::RecordLiftNode"), record_lift_entry::<T>()),
(crate::ProtoNodeIdentifier::new("core_types::record::RecordExtractNode"), record_extract_entry::<T>()),
]
}
/// The lift bridge row for `T`: a plain producer onto a record wire. One
/// exists per wire type while plain and record worlds coexist.
pub fn record_lift_entry<T: Clone + Send + Sync + 'static>() -> RegistryEntry {
RegistryEntry {
io: NodeIOTypes::new(concrete!(Context), record_type::<T>(), vec![edge_type::<T>()]),
constructor: |inputs| {
if inputs.len() != 1 {
return Err(ConstructionError::Arity { expected: 1, got: inputs.len() });
}
let mut inputs = inputs.into_iter();
let node = crate::record::RecordLift::<T, _>::new(inputs.next().unwrap().downcast::<T>()?);
Ok(EdgeHandle::new_record::<T>(std::sync::Arc::new(node) as std::sync::Arc<ErasedRecordNode>))
},
}
}
/// The extract bridge row for `T`: a record wire into a plain consumer.
pub fn record_extract_entry<T: Clone + Send + Sync + 'static>() -> RegistryEntry {
RegistryEntry {
io: NodeIOTypes::new(concrete!(Context), concrete!(T), vec![record_edge_type::<T>()]),
constructor: |inputs| {
if inputs.len() != 1 {
return Err(ConstructionError::Arity { expected: 1, got: inputs.len() });
}
let mut inputs = inputs.into_iter();
let edge = inputs.next().unwrap();
let layout = edge.layout().ok_or(ConstructionError::MissingLayout)?.clone();
let node = crate::record::RecordExtract::<T, _>::new(edge.downcast_record::<T>()?, &layout);
Ok(EdgeHandle::new(std::sync::Arc::new(node) as std::sync::Arc<ErasedNode<T>>))
},
}
}
pub fn construct(entry: &RegistryEntry, inputs: Vec<EdgeHandle>) -> Result<EdgeHandle, ConstructionError> {
if inputs.len() != entry.io.inputs.len() {
return Err(ConstructionError::Arity {

View File

@@ -13,11 +13,41 @@ pub fn value_edge<T: Clone + crate::WasmNotSend + crate::WasmNotSync + 'static>(
crate::registry::EdgeHandle::new(std::sync::Arc::new(ClonedNode(value)) as std::sync::Arc<crate::registry::ErasedNode<T>>)
}
/// The native record edge of a constant: the element lifts onto the record
/// wire per evaluation, so value sources need no spliced adapter.
/// The node behind every value edge: clones its constant onto the record
/// wire per evaluation.
pub struct ValueSource<T> {
value: T,
layout: crate::record::Layout,
}
impl<T: Clone + Send + Sync + 'static> ValueSource<T> {
pub fn new(value: T) -> Self {
Self {
value,
layout: crate::record::Layout::default().with_writes(0, crate::record::element_write::<T>(), &[]),
}
}
}
impl<'e, C, T> crate::node::Node<C> for ValueSource<T>
where
C: crate::context::ExtractArena<ArenaRef = &'e crate::arena::Arena>,
T: Clone + Send + Sync + 'static,
{
type Output = crate::record::RecordValue<'e>;
fn eval(&self, input: &C) -> crate::gpoll::GPoll<crate::record::RecordValue<'e>> {
crate::record::lift_poll(crate::gpoll::GPoll::Final(self.value.clone()), &self.layout, input.arena())
}
fn layout(&self) -> Option<&crate::record::Layout> {
Some(&self.layout)
}
}
/// The native record edge of a constant.
pub fn record_value_edge<T: Clone + Send + Sync + 'static>(value: T) -> crate::registry::EdgeHandle {
let node = crate::record::RecordLift::<T, _>::new(ClonedNode(value));
crate::registry::EdgeHandle::new_record::<T>(std::sync::Arc::new(node) as std::sync::Arc<crate::registry::ErasedRecordNode>)
crate::registry::EdgeHandle::new_record::<T>(std::sync::Arc::new(ValueSource::new(value)) as std::sync::Arc<crate::registry::ErasedRecordNode>)
}
impl<T: Clone> ClonedNode<T> {

File diff suppressed because it is too large Load Diff

View File

@@ -35,13 +35,12 @@ pub(crate) struct ParsedNodeFn {
pub(crate) output_type: Type,
pub(crate) is_async: bool,
pub(crate) fields: Vec<ParsedField>,
pub(crate) attribute_reads: Vec<AttributeRead>,
pub(crate) body: TokenStream2,
pub(crate) description: String,
}
/// An `Attr<Marker>` parameter: a declared attribute read on the carrier's
/// items, not a wired input.
/// An `Attr<Marker>` slot in a parameter's read tuple: a declared attribute
/// read on that input's wire, not a wired input of its own.
#[derive(Clone, Debug)]
pub(crate) struct AttributeRead {
pub(crate) pat_ident: PatIdent,
@@ -49,34 +48,54 @@ pub(crate) struct AttributeRead {
}
/// The write half of a record kernel's return: the element type in the first
/// tuple slot and the attribute markers written after it. `None` unless the
/// value is a well-formed write tuple (a non-`Attr` element first, then only
/// `Attr` slots, at least one).
/// tuple slot, then the attribute markers written and the ones removed. `None`
/// unless the value is a well-formed write tuple (a non-marker element first,
/// then only `Attr` and `RemoveAttr` slots, at least one).
pub(crate) struct RecordWrites {
pub(crate) element: Type,
pub(crate) markers: Vec<Type>,
pub(crate) removes: Vec<Type>,
}
pub(crate) fn record_writes(value: &Type) -> Option<RecordWrites> {
let Type::Tuple(tuple) = value else { return None };
let mut slots = tuple.elems.iter();
let element = slots.next()?;
if attr_marker(element).is_some() {
if attr_marker(element).is_some() || remove_attr_marker(element).is_some() {
return None;
}
let markers: Option<Vec<Type>> = slots.map(attr_marker).collect();
let markers = markers?;
(!markers.is_empty()).then(|| RecordWrites {
let mut markers = Vec::new();
let mut removes = Vec::new();
for slot in slots {
if let Some(marker) = attr_marker(slot) {
markers.push(marker);
} else if let Some(marker) = remove_attr_marker(slot) {
removes.push(marker);
} else {
return None;
}
}
(!markers.is_empty() || !removes.is_empty()).then(|| RecordWrites {
element: element.clone(),
markers,
removes,
})
}
/// Returns the marker type of an `Attr<Marker>` type, if `ty` is one.
pub(crate) fn attr_marker(ty: &Type) -> Option<Type> {
marker_of(ty, "Attr")
}
/// Returns the marker type of a `RemoveAttr<Marker>` type, if `ty` is one.
pub(crate) fn remove_attr_marker(ty: &Type) -> Option<Type> {
marker_of(ty, "RemoveAttr")
}
fn marker_of(ty: &Type, wrapper: &str) -> Option<Type> {
let Type::Path(path) = ty else { return None };
let segment = path.path.segments.last()?;
if segment.ident != "Attr" {
if segment.ident != wrapper {
return None;
}
let PathArguments::AngleBracketed(args) = &segment.arguments else { return None };
@@ -178,6 +197,9 @@ pub struct ParsedField {
pub number_step: Option<LitFloat>,
pub unit: Option<LitStr>,
pub is_data_field: bool,
/// The attribute reads destructured from this input's tuple, resolved
/// against this input's wire.
pub(crate) attribute_reads: Vec<AttributeRead>,
}
#[derive(Clone, Debug)]
@@ -637,7 +659,7 @@ fn parse_node_fn(attr: TokenStream2, item: TokenStream2) -> syn::Result<ParsedNo
let fn_generics = input_fn.sig.generics.params.into_iter().collect();
let is_async = input_fn.sig.asyncness.is_some();
let (input, fields, attribute_reads) = parse_inputs(&input_fn.sig.inputs)?;
let (input, fields) = parse_inputs(&input_fn.sig.inputs)?;
let output_type = parse_output(&input_fn.sig.output)?;
let where_clause = input_fn.sig.generics.where_clause;
let body = input_fn.block.to_token_stream();
@@ -669,16 +691,14 @@ fn parse_node_fn(attr: TokenStream2, item: TokenStream2) -> syn::Result<ParsedNo
output_type,
is_async,
fields,
attribute_reads,
where_clause,
body,
description,
})
}
fn parse_inputs(inputs: &Punctuated<FnArg, Comma>) -> syn::Result<(Input, Vec<ParsedField>, Vec<AttributeRead>)> {
fn parse_inputs(inputs: &Punctuated<FnArg, Comma>) -> syn::Result<(Input, Vec<ParsedField>)> {
let mut fields = Vec::new();
let mut attribute_reads = Vec::new();
let mut input = None;
for (index, arg) in inputs.iter().enumerate() {
@@ -715,18 +735,14 @@ fn parse_inputs(inputs: &Punctuated<FnArg, Comma>) -> syn::Result<(Input, Vec<Pa
context_features,
});
} else if let Pat::Ident(pat_ident) = &**pat {
if let Some(marker) = attr_marker(ty) {
if !attrs.iter().all(|attr| attr.path().is_ident("doc")) {
return Err(Error::new_spanned(pat_ident, "attribute parameters take no field attributes"));
}
attribute_reads.push(AttributeRead {
pat_ident: pat_ident.clone(),
marker,
});
} else {
let field = parse_field(pat_ident.clone(), (**ty).clone(), attrs).map_err(|e| Error::new_spanned(pat_ident, format!("Failed to parse argument '{}': {}", pat_ident.ident, e)))?;
fields.push(field);
if attr_marker(ty).is_some() {
return Err(Error::new_spanned(pat_ident, "an attribute read binds to an input: destructure it as `(value, Attr<..>)`"));
}
let field = parse_field(pat_ident.clone(), (**ty).clone(), attrs).map_err(|e| Error::new_spanned(pat_ident, format!("Failed to parse argument '{}': {}", pat_ident.ident, e)))?;
fields.push(field);
} else if let Pat::Tuple(pat_tuple) = &**pat {
let field = parse_read_tuple(pat_tuple, ty, attrs, index)?;
fields.push(field);
} else if let Pat::Wild(wild) = &**pat {
let pat_ident = PatIdent {
attrs: wild.attrs.clone(),
@@ -746,7 +762,86 @@ fn parse_inputs(inputs: &Punctuated<FnArg, Comma>) -> syn::Result<(Input, Vec<Pa
}
let input = input.ok_or_else(|| Error::new_spanned(inputs, "Expected at least one input argument. The first argument should be the node input type."))?;
Ok((input, fields, attribute_reads))
Ok((input, fields))
}
/// Splits a lazy input's `Output = (T, Attr<..>..)` tuple into the element
/// type (the wire type) and the declared reads on that edge. A tuple without
/// `Attr` slots is an ordinary tuple output and passes through untouched.
fn split_lazy_reads(output_type: Type) -> syn::Result<(Type, Vec<AttributeRead>)> {
let Type::Tuple(tuple) = &output_type else {
return Ok((output_type, Vec::new()));
};
if !tuple.elems.iter().any(|slot| attr_marker(slot).is_some()) {
return Ok((output_type, Vec::new()));
}
let spelling = "a lazy input with attribute reads declares `Output = (T, Attr<..>)`";
let mut slots = tuple.elems.iter();
let element = slots.next().ok_or_else(|| Error::new_spanned(tuple, spelling))?;
if attr_marker(element).is_some() {
return Err(Error::new_spanned(element, spelling));
}
let attribute_reads: Vec<AttributeRead> = slots
.enumerate()
.map(|(index, slot)| {
let marker = attr_marker(slot).ok_or_else(|| Error::new_spanned(slot, spelling))?;
Ok(AttributeRead {
pat_ident: PatIdent {
attrs: Vec::new(),
by_ref: None,
mutability: None,
ident: format_ident!("__lazy_read_{}", index, span = slot.span()),
subpat: None,
},
marker,
})
})
.collect::<syn::Result<_>>()?;
Ok((element.clone(), attribute_reads))
}
/// Parses a `(value, reads..): (T, Attr<..>..)` parameter: the value component
/// is an ordinary field of the value type, each `Attr` component a read bound
/// to this input's wire.
fn parse_read_tuple(pat_tuple: &syn::PatTuple, ty: &Type, attrs: &[Attribute], index: usize) -> syn::Result<ParsedField> {
let spelling = "an input with attribute reads destructures as `(value, Attr<..>)` over `(T, Attr<..>)`";
let Type::Tuple(ty_tuple) = ty else {
return Err(Error::new_spanned(ty, spelling));
};
if pat_tuple.elems.len() != ty_tuple.elems.len() || ty_tuple.elems.len() < 2 {
return Err(Error::new_spanned(pat_tuple, spelling));
}
let mut slots = pat_tuple.elems.iter().zip(ty_tuple.elems.iter());
let (value_pat, value_ty) = slots.next().expect("length checked above");
if attr_marker(value_ty).is_some() {
return Err(Error::new_spanned(value_ty, spelling));
}
let value_ident = match value_pat {
Pat::Ident(pat_ident) => pat_ident.clone(),
Pat::Wild(wild) => PatIdent {
attrs: wild.attrs.clone(),
by_ref: None,
mutability: None,
ident: format_ident!("_value{}", index, span = wild.underscore_token.span),
subpat: None,
},
_ => return Err(Error::new_spanned(value_pat, "Expected a simple identifier for the value component")),
};
let attribute_reads: Vec<AttributeRead> = slots
.map(|(pat, ty)| {
let marker = attr_marker(ty).ok_or_else(|| Error::new_spanned(ty, spelling))?;
let Pat::Ident(pat_ident) = pat else {
return Err(Error::new_spanned(pat, "Expected a simple identifier for the attribute read"));
};
Ok(AttributeRead {
pat_ident: pat_ident.clone(),
marker,
})
})
.collect::<syn::Result<_>>()?;
let mut field = parse_field(value_ident.clone(), value_ty.clone(), attrs).map_err(|e| Error::new_spanned(&value_ident, format!("Failed to parse argument '{}': {}", value_ident.ident, e)))?;
field.attribute_reads = attribute_reads;
Ok(field)
}
/// Parse context feature identifiers from the trait bounds of a context parameter.
@@ -967,6 +1062,7 @@ fn parse_field(pat_ident: PatIdent, ty: Type, attrs: &[Attribute]) -> syn::Resul
.transpose()?
.unwrap_or_default();
let (output_type, attribute_reads) = split_lazy_reads(output_type)?;
Ok(ParsedField {
pat_ident,
ty: ParsedFieldType::Node(NodeParsedField {
@@ -981,6 +1077,7 @@ fn parse_field(pat_ident: PatIdent, ty: Type, attrs: &[Attribute]) -> syn::Resul
number_step,
unit,
is_data_field,
attribute_reads,
})
} else {
let implementations = extract_attribute(attrs, "implementations")
@@ -1037,6 +1134,7 @@ fn parse_field(pat_ident: PatIdent, ty: Type, attrs: &[Attribute]) -> syn::Resul
number_step,
unit,
is_data_field,
attribute_reads: Vec::new(),
})
}
}
@@ -1153,6 +1251,7 @@ impl ParsedNodeFn {
number_step: None,
unit: None,
is_data_field: false,
attribute_reads: Vec::new(),
};
self.fields.push(hidden_field(
"_runtime",
@@ -1304,7 +1403,6 @@ mod tests {
},
output_type: parse_quote!(f64),
is_async: false,
attribute_reads: vec![],
fields: vec![ParsedField {
pat_ident: pat_ident("b"),
name: None,
@@ -1327,6 +1425,7 @@ mod tests {
number_step: None,
unit: None,
is_data_field: false,
attribute_reads: Vec::new(),
}],
body: TokenStream2::new(),
description: String::from("Multi\nLine\n"),
@@ -1381,7 +1480,6 @@ mod tests {
},
output_type: parse_quote!(T),
is_async: false,
attribute_reads: vec![],
fields: vec![
ParsedField {
pat_ident: pat_ident("transform_target"),
@@ -1397,6 +1495,7 @@ mod tests {
number_step: None,
unit: None,
is_data_field: false,
attribute_reads: Vec::new(),
},
ParsedField {
pat_ident: pat_ident("translate"),
@@ -1420,6 +1519,7 @@ mod tests {
number_step: None,
unit: None,
is_data_field: false,
attribute_reads: Vec::new(),
},
],
body: TokenStream2::new(),
@@ -1472,7 +1572,6 @@ mod tests {
},
output_type: parse_quote!(Vector),
is_async: false,
attribute_reads: vec![],
fields: vec![ParsedField {
pat_ident: pat_ident("radius"),
name: None,
@@ -1495,6 +1594,7 @@ mod tests {
number_step: None,
unit: None,
is_data_field: false,
attribute_reads: Vec::new(),
}],
body: TokenStream2::new(),
description: "Test\n".into(),
@@ -1545,7 +1645,6 @@ mod tests {
},
output_type: parse_quote!(List<Raster<P>>),
is_async: false,
attribute_reads: vec![],
fields: vec![ParsedField {
pat_ident: pat_ident("shadows"),
name: None,
@@ -1573,6 +1672,7 @@ mod tests {
number_step: None,
unit: None,
is_data_field: false,
attribute_reads: Vec::new(),
}],
body: TokenStream2::new(),
description: String::new(),
@@ -1630,7 +1730,6 @@ mod tests {
},
output_type: parse_quote!(f64),
is_async: false,
attribute_reads: vec![],
fields: vec![ParsedField {
pat_ident: pat_ident("b"),
name: None,
@@ -1653,6 +1752,7 @@ mod tests {
number_step: None,
unit: None,
is_data_field: false,
attribute_reads: Vec::new(),
}],
body: TokenStream2::new(),
description: String::new(),
@@ -1718,7 +1818,6 @@ mod tests {
},
output_type: parse_quote!(List<Raster<CPU>>),
is_async: true,
attribute_reads: vec![],
fields: vec![ParsedField {
pat_ident: pat_ident("path"),
name: None,
@@ -1741,6 +1840,7 @@ mod tests {
number_step: None,
unit: None,
is_data_field: false,
attribute_reads: Vec::new(),
}],
body: TokenStream2::new(),
description: String::new(),
@@ -1791,7 +1891,6 @@ mod tests {
},
output_type: parse_quote!(i32),
is_async: false,
attribute_reads: vec![],
fields: vec![],
body: TokenStream2::new(),
description: String::new(),

View File

@@ -247,6 +247,7 @@ impl PerPixelAdjustCodegen<'_> {
number_step: None,
unit: None,
is_data_field: false,
attribute_reads: Vec::new(),
});
// find exactly one gpu_image field, runtime doesn't support more than 1 atm
@@ -317,7 +318,6 @@ impl PerPixelAdjustCodegen<'_> {
output_type: raster_gpu,
is_async: false,
fields,
attribute_reads: Vec::new(),
body,
description: self.parsed.description.clone(),
};

View File

@@ -1,4 +1,4 @@
use crate::parsing::{Implementation, NodeParsedField, ParsedField, ParsedFieldType, ParsedNodeFn, RegularParsedField, attr_marker, record_writes};
use crate::parsing::{Implementation, NodeParsedField, ParsedField, ParsedFieldType, ParsedNodeFn, RegularParsedField, attr_marker, record_writes, remove_attr_marker};
use proc_macro_error2::emit_error;
use quote::{ToTokens, quote};
use syn::spanned::Spanned;
@@ -14,6 +14,7 @@ pub fn validate_node_fn(parsed: &ParsedNodeFn) -> syn::Result<()> {
validate_async_source,
validate_lend_fields,
validate_record_io,
validate_lazy_reads,
];
for validator in validators {
@@ -26,19 +27,23 @@ pub fn validate_node_fn(parsed: &ParsedNodeFn) -> syn::Result<()> {
fn validate_record_io(parsed: &ParsedNodeFn) {
let value = crate::codegen::slot_value_type(&parsed.output_type);
if let Type::Tuple(tuple) = &value {
let has_attr_slot = tuple.elems.iter().any(|slot| attr_marker(slot).is_some());
if has_attr_slot && record_writes(&value).is_none() {
let has_marker_slot = tuple.elems.iter().any(|slot| attr_marker(slot).is_some() || remove_attr_marker(slot).is_some());
if has_marker_slot && record_writes(&value).is_none() {
emit_error!(
parsed.output_type.span(),
"a record return tuple is the element first, then only `Attr<..>` writes"
"a record return tuple is the element first, then only `Attr<..>` writes and `RemoveAttr<..>` deletions"
);
}
} else if attr_marker(&value).is_some() {
emit_error!(parsed.output_type.span(), "an `Attr<..>` write needs an element in the first tuple slot, e.g. `(T, Attr<..>)`");
} else if attr_marker(&value).is_some() || remove_attr_marker(&value).is_some() {
emit_error!(parsed.output_type.span(), "an attribute write needs an element in the first tuple slot, e.g. `(T, Attr<..>)`");
}
let writes = record_writes(&value);
if parsed.attribute_reads.is_empty() && writes.is_none() {
let has_reads = parsed
.fields
.iter()
.any(|field| !field.attribute_reads.is_empty() && matches!(field.ty, ParsedFieldType::Regular(_)));
if !has_reads && writes.is_none() {
return;
}
@@ -54,6 +59,35 @@ fn validate_record_io(parsed: &ParsedNodeFn) {
emit_error!(field.pat_ident.span(), "record nodes take no lazy inputs yet");
}
}
for (index, field) in parsed.fields.iter().enumerate() {
if field.attribute_reads.is_empty() {
continue;
}
if field.is_data_field {
emit_error!(field.pat_ident.span(), "a `#[data]` field has no wire to read attributes from");
continue;
}
match &field.ty {
ParsedFieldType::Regular(RegularParsedField { lend: Some(_), .. }) => {
emit_error!(field.pat_ident.span(), "attribute reads need an owned value; take `T` instead of `&T`");
}
ParsedFieldType::Regular(RegularParsedField { ty, implementations, .. }) => {
if matches!(ty, Type::Tuple(tuple) if tuple.elems.is_empty()) {
emit_error!(field.pat_ident.span(), "attribute-only inputs are not supported yet; the value component cannot be `()`");
}
let is_token_carrier = index == 0 && implementations.is_empty() && crate::codegen::unbounded_generic(parsed, ty).is_some();
if !is_token_carrier && crate::codegen::contains_open_generic(parsed, ty) {
emit_error!(
field.pat_ident.span(),
"a reading input's value is monomorphic for now; use a concrete type or an unbounded passthrough generic in the primary input"
);
}
}
// Lazy-input reads are validated by `validate_lazy_reads`; a
// record-io node already rejects lazy inputs above.
ParsedFieldType::Node(_) => {}
}
}
let Some(carrier) = parsed.fields.first() else {
emit_error!(
@@ -75,9 +109,6 @@ fn validate_record_io(parsed: &ParsedNodeFn) {
};
let no_carrier = matches!(carrier_ty, Type::Tuple(tuple) if tuple.elems.is_empty());
if no_carrier && !parsed.attribute_reads.is_empty() {
emit_error!(carrier.pat_ident.span(), "a node without a primary input has no attributes to read");
}
let token = match (no_carrier, &carrier.ty) {
(false, ParsedFieldType::Regular(RegularParsedField { ty, implementations, .. })) if implementations.is_empty() => crate::codegen::unbounded_generic(parsed, ty),
_ => None,
@@ -107,13 +138,15 @@ fn validate_record_io(parsed: &ParsedNodeFn) {
}
}
let mut seen_reads: Vec<String> = Vec::new();
for read in &parsed.attribute_reads {
let marker = read.marker.to_token_stream().to_string();
if seen_reads.contains(&marker) {
emit_error!(read.pat_ident.span(), "attribute `{}` is read twice", marker);
for field in &parsed.fields {
let mut seen_reads: Vec<String> = Vec::new();
for read in &field.attribute_reads {
let marker = read.marker.to_token_stream().to_string();
if seen_reads.contains(&marker) {
emit_error!(read.pat_ident.span(), "attribute `{}` is read twice from `{}`", marker, field.pat_ident.ident);
}
seen_reads.push(marker);
}
seen_reads.push(marker);
}
if let Some(writes) = &writes {
let mut seen_writes: Vec<String> = Vec::new();
@@ -124,6 +157,54 @@ fn validate_record_io(parsed: &ParsedNodeFn) {
}
seen_writes.push(written);
}
let mut seen_removes: Vec<String> = Vec::new();
for marker in &writes.removes {
let removed = marker.to_token_stream().to_string();
if seen_removes.contains(&removed) {
emit_error!(parsed.output_type.span(), "attribute `{}` is removed twice", removed);
}
if seen_writes.contains(&removed) {
emit_error!(parsed.output_type.span(), "attribute `{}` is both written and removed", removed);
}
seen_removes.push(removed);
}
if no_carrier && !writes.removes.is_empty() {
emit_error!(parsed.output_type.span(), "a node without a primary input writes a fresh record; there is nothing to remove");
}
}
}
fn validate_lazy_reads(parsed: &ParsedNodeFn) {
if !crate::codegen::has_lazy_reads(parsed) {
return;
}
if !crate::codegen::record_flip(parsed) {
emit_error!(
parsed.fn_name.span(),
"attribute reads on a lazy input need the record lowering; routing, `plain`, shader, batch, and non-row-assignable generic nodes keep the plain one"
);
}
for field in &parsed.fields {
let ParsedFieldType::Node(NodeParsedField { output_type, .. }) = &field.ty else {
continue;
};
if field.attribute_reads.is_empty() {
continue;
}
if crate::codegen::unbounded_generic(parsed, output_type).is_some() {
emit_error!(
field.pat_ident.span(),
"an unbounded generic source forwards its whole record; attribute reads need a concrete output type"
);
}
let mut seen: Vec<String> = Vec::new();
for read in &field.attribute_reads {
let marker = read.marker.to_token_stream().to_string();
if seen.contains(&marker) {
emit_error!(read.marker.span(), "attribute `{}` is read twice from `{}`", marker, field.pat_ident.ident);
}
seen.push(marker);
}
}
}

View File

@@ -1,10 +1,11 @@
//! Pilot record nodes exercising the macro's record-tier attribute io:
//! offset reads and writes against record edges, the ElToken byte-carry for
//! passthrough elements, and the `_: ()` no-carrier form. These are the
//! flat-wave law tests; the node forms are the production authoring surface,
//! and the wiring is by hand until the compiler pass constructs layouts.
//! per-input tuple reads resolved against each input's wire, offset writes,
//! `RemoveAttr` layout subtraction, the ElToken byte-carry for passthrough
//! elements, and the `_: ()` no-carrier form. These are the flat-wave law
//! tests; the node forms are the production authoring surface, and the
//! wiring is by hand until the compiler pass constructs layouts.
use core_types::attribute::{Attr, Opacity};
use core_types::attribute::{Attr, Opacity, RemoveAttr};
use core_types::context::ExtractArena;
use core_types::gpoll::{ErrorKind, GraphError, Interrupt};
use core_types::{Context, Ctx};
@@ -17,7 +18,7 @@ core_types::attribute! {
}
#[node_macro::node(category("Test"))]
fn multiply_opacity(_: impl Ctx, element: f64, factor: f64, opacity: Attr<Opacity>) -> (f64, Attr<Opacity>) {
fn multiply_opacity(_: impl Ctx, (element, opacity): (f64, Attr<Opacity>), factor: f64) -> (f64, Attr<Opacity>) {
(element, Attr(*opacity * factor))
}
@@ -27,12 +28,12 @@ fn measure(_: impl Ctx, element: f64) -> (f64, Attr<Length>) {
}
#[node_macro::node(category("Test"))]
fn shade(_: impl Ctx, element: f64, opacity: Attr<Opacity>) -> f64 {
fn shade(_: impl Ctx, (element, opacity): (f64, Attr<Opacity>)) -> f64 {
element * *opacity
}
#[node_macro::node(category("Test"))]
fn checked_multiply_opacity(_: impl Ctx, element: f64, factor: f64, opacity: Attr<Opacity>) -> Result<(f64, Attr<Opacity>), Interrupt> {
fn checked_multiply_opacity(_: impl Ctx, (element, opacity): (f64, Attr<Opacity>), factor: f64) -> Result<(f64, Attr<Opacity>), Interrupt> {
if factor < 0. {
return Err(GraphError::new("negative factor").into());
}
@@ -40,12 +41,12 @@ fn checked_multiply_opacity(_: impl Ctx, element: f64, factor: f64, opacity: Att
}
#[node_macro::node(category("Test"))]
fn scale(_: impl Ctx, element: f64, factor: &f64, opacity: Attr<Opacity>) -> (f64, Attr<Opacity>) {
fn scale(_: impl Ctx, (element, opacity): (f64, Attr<Opacity>), factor: &f64) -> (f64, Attr<Opacity>) {
(element * *factor, Attr(*opacity))
}
#[node_macro::node(category("Test"))]
fn fade<T>(_: impl Ctx, element: T, factor: f64, opacity: Attr<Opacity>) -> (T, Attr<Opacity>) {
fn fade<T>(_: impl Ctx, (element, opacity): (T, Attr<Opacity>), factor: f64) -> (T, Attr<Opacity>) {
(element, Attr(*opacity * factor))
}
@@ -55,7 +56,7 @@ fn source_opacity(_: impl Ctx, _: (), element: f64, opacity: f64) -> (f64, Attr<
}
#[node_macro::node(category("Test"))]
fn label<'e>(ctx: impl Ctx + ExtractArena<'e>, element: f64, text: String, label: Attr<Label>) -> Result<(f64, Attr<'e, Label>), Interrupt> {
fn label<'e>(ctx: impl Ctx + ExtractArena<'e>, (element, label): (f64, Attr<Label>), text: String) -> Result<(f64, Attr<'e, Label>), Interrupt> {
let joined = format!("{}{text}", *label);
let (parked, _) = ctx.arena().alloc(joined).ok_or(GraphError {
kind: ErrorKind::ArenaExhausted,
@@ -64,6 +65,52 @@ fn label<'e>(ctx: impl Ctx + ExtractArena<'e>, element: f64, text: String, label
Ok((element, Attr(parked.as_str())))
}
#[node_macro::node(category("Test"))]
fn transfer_opacity(_: impl Ctx, (element, opacity): (f64, Attr<Opacity>), (other, other_opacity): (f64, Attr<Opacity>)) -> (f64, Attr<Opacity>) {
(element + other, Attr(*opacity * *other_opacity))
}
#[node_macro::node(category("Test"))]
fn strip_opacity<T>(_: impl Ctx, element: T) -> (T, RemoveAttr<Opacity>) {
(element, RemoveAttr::new())
}
#[node_macro::node(category("Test"))]
fn relength(_: impl Ctx, element: f64) -> (f64, RemoveAttr<Opacity>, Attr<Length>) {
(element, RemoveAttr::new(), Attr(element * 2.))
}
#[node_macro::node(category("Test"))]
fn boost(_: impl Ctx, element: f64, factor: f64) -> f64 {
element * factor
}
#[node_macro::node(category("Test"))]
fn boost_poll(_: impl Ctx, element: f64, factor: f64) -> core_types::gpoll::GPoll<f64> {
core_types::gpoll::GPoll::Final(element * factor)
}
#[node_macro::node(category("Test"))]
fn offset(_: impl Ctx, element: f64, by: &f64) -> f64 {
element + *by
}
#[node_macro::node(category("Test"))]
async fn double_async(_: impl Ctx, element: f64) -> f64 {
element * 2.
}
#[node_macro::node(category("Test"))]
fn fallback(
ctx: impl Ctx,
_: (),
#[expose] content: impl Node<Context<'_>, Output = (f64, Attr<Opacity>)>,
#[expose] alternate: impl Node<Context<'_>, Output = f64>,
) -> Result<f64, Interrupt> {
let (element, opacity) = content.eval(ctx)?;
Ok(if *opacity > 0. { element } else { alternate.eval(ctx)? })
}
#[node_macro::node(category("Test"))]
fn pick<T>(ctx: impl Ctx, take_second: bool, first: impl Node<Context<'_>, Output = T>, second: impl Node<Context<'_>, Output = T>) -> Result<T, Interrupt> {
if take_second { second.eval(ctx) } else { first.eval(ctx) }
@@ -158,6 +205,12 @@ mod tests {
stack::reserve(layouts.iter().map(|layout| layout.frame_bytes()).sum());
}
fn lifted_value<T: Clone + Send + Sync + 'static>(value: T) -> (core_types::record::RecordLift<T, ValueNode<T>>, Layout) {
let lift = core_types::record::RecordLift::<T, _>::new(ValueNode(value));
let layout = Node::<ContextImpl>::layout(&lift).unwrap().clone();
(lift, layout)
}
fn bare_source(layout: &Layout, element: f64) -> RecordSourceNode<f64> {
RecordSourceNode {
layout: layout.clone(),
@@ -388,6 +441,267 @@ mod tests {
assert_eq!(unsafe { rec.read::<f64>(scaled.offset_of(Opacity::NAME, 0).unwrap()) }, 0.5);
}
#[test]
fn secondary_input_reads_bind_to_their_own_wire() {
let arena = Arena::new(1024).unwrap();
let generations = [];
let scope = scope_fixture(&generations, &arena);
let ctx = ContextImpl::root(&scope);
let carrier_layout = f64_layout(&["opacity"]);
let secondary_layout = f64_layout(&["opacity"]);
let transferred = transfer_opacity_layout(&carrier_layout);
reserve_for(&[&carrier_layout, &secondary_layout, &transferred]);
let chain = TransferOpacityNode::new(
f64_record_source(&carrier_layout, 2., vec![(carrier_layout.offset_of("opacity", 0).unwrap(), 0.5)]),
f64_record_source(&secondary_layout, 3., vec![(secondary_layout.offset_of("opacity", 0).unwrap(), 0.25)]),
&carrier_layout,
&secondary_layout,
);
let GPoll::Final(value) = chain.eval(&ctx) else {
panic!("expected a final record");
};
let rec = transferred.rec(&value);
assert_eq!(unsafe { rec.element::<f64>() }, 5.);
assert_eq!(unsafe { rec.read::<f64>(transferred.offset_of(Opacity::NAME, 0).unwrap()) }, 0.125);
let bare_secondary = f64_layout(&[]);
let defaulted = TransferOpacityNode::new(
f64_record_source(&carrier_layout, 2., vec![(carrier_layout.offset_of("opacity", 0).unwrap(), 0.5)]),
bare_source(&bare_secondary, 3.),
&carrier_layout,
&bare_secondary,
);
let GPoll::Final(value) = defaulted.eval(&ctx) else {
panic!("expected a final record");
};
let rec = transferred.rec(&value);
assert_eq!(unsafe { rec.read::<f64>(transferred.offset_of(Opacity::NAME, 0).unwrap()) }, 0.5, "an absent secondary attribute reads its default");
}
#[test]
fn a_flipped_node_carries_its_primary_inputs_fields() {
let arena = Arena::new(1024).unwrap();
let generations = [];
let scope = scope_fixture(&generations, &arena);
let ctx = ContextImpl::root(&scope);
let source_layout = f64_layout(&["opacity"]);
let factor = core_types::record::RecordLift::<f64, _>::new(ValueNode(3.));
let factor_layout = Node::<ContextImpl>::layout(&factor).unwrap().clone();
reserve_for(&[&source_layout]);
let node = BoostNode::new(
f64_record_source(&source_layout, 2., vec![(source_layout.offset_of("opacity", 0).unwrap(), 0.25)]),
factor,
&source_layout,
&factor_layout,
);
let out_layout = Node::<ContextImpl>::layout(&node).unwrap().clone();
let opacity_offset = out_layout.offset_of(Opacity::NAME, 0).expect("the primary input's fields pass through to the output");
let GPoll::Final(value) = node.eval(&ctx) else {
panic!("expected a final record");
};
let rec = out_layout.rec(&value);
assert_eq!(unsafe { rec.element::<f64>() }, 6.);
assert_eq!(unsafe { rec.read::<f64>(opacity_offset) }, 0.25);
}
#[test]
fn a_poll_kernel_carries_its_primary_inputs_fields() {
let arena = Arena::new(1024).unwrap();
let generations = [];
let scope = scope_fixture(&generations, &arena);
let ctx = ContextImpl::root(&scope);
let source_layout = f64_layout(&["opacity"]);
let (factor, factor_layout) = lifted_value(3.);
reserve_for(&[&source_layout]);
let node = BoostPollNode::new(
f64_record_source(&source_layout, 2., vec![(source_layout.offset_of("opacity", 0).unwrap(), 0.25)]),
factor,
&source_layout,
&factor_layout,
);
let out_layout = Node::<ContextImpl>::layout(&node).unwrap().clone();
let opacity_offset = out_layout.offset_of(Opacity::NAME, 0).expect("the primary input's fields pass through the poll kernel");
let GPoll::Final(value) = node.eval(&ctx) else {
panic!("expected a final record");
};
let rec = out_layout.rec(&value);
assert_eq!(unsafe { rec.element::<f64>() }, 6.);
assert_eq!(unsafe { rec.read::<f64>(opacity_offset) }, 0.25);
}
#[test]
fn a_byte_carried_spilled_borrow_parks_and_survives_the_carrier_eval() {
let arena = Arena::new(1024).unwrap();
let generations = [];
let scope = scope_fixture(&generations, &arena);
let ctx = ContextImpl::root(&scope);
let carrier_layout = f64_layout(&["opacity"]);
let by_layout = f64_layout(&["opacity", "length"]);
assert!(!by_layout.is_inline(), "the borrow must point into a spilled frame to exercise the park");
reserve_for(&[&carrier_layout, &by_layout]);
let node = OffsetNode::new(
f64_record_source(&carrier_layout, 2., vec![(carrier_layout.offset_of("opacity", 0).unwrap(), 0.25)]),
f64_record_source(&by_layout, 40., vec![]),
&carrier_layout,
&by_layout,
);
let out_layout = Node::<ContextImpl>::layout(&node).unwrap().clone();
let GPoll::Final(value) = node.eval(&ctx) else {
panic!("expected a final record");
};
let rec = out_layout.rec(&value);
assert_eq!(unsafe { rec.element::<f64>() }, 42., "the parked borrow survives the carrier evaluation reusing its frame");
assert_eq!(unsafe { rec.read::<f64>(out_layout.offset_of(Opacity::NAME, 0).unwrap()) }, 0.25);
}
struct InlineRuntime;
impl core_types::runtime::Runtime for InlineRuntime {
fn spawn(&self, _source: SourceId, mut future: core_types::runtime::SourceFuture) -> bool {
let mut task_ctx = std::task::Context::from_waker(std::task::Waker::noop());
assert!(future.as_mut().poll(&mut task_ctx).is_ready(), "the inline runtime completes tasks at spawn");
true
}
}
#[test]
fn an_async_source_carries_its_primary_inputs_fields_around_the_slot() {
let arena = Arena::new(1024).unwrap();
let generations = [];
let scope = scope_fixture(&generations, &arena);
let ctx = ContextImpl::root(&scope);
let source_layout = f64_layout(&["opacity"]);
let (runtime, runtime_layout) = lifted_value(core_types::runtime::RuntimeHandle(std::sync::Arc::new(InlineRuntime)));
let (source_id, source_id_layout) = lifted_value(7 as SourceId);
reserve_for(&[&source_layout]);
let node = DoubleAsyncNode::new(
f64_record_source(&source_layout, 3., vec![(source_layout.offset_of("opacity", 0).unwrap(), 0.25)]),
runtime,
source_id,
&source_layout,
&runtime_layout,
&source_id_layout,
);
let out_layout = Node::<ContextImpl>::layout(&node).unwrap().clone();
let opacity_offset = out_layout.offset_of(Opacity::NAME, 0).expect("the carrier's fields pass through the async source");
let GPoll::Final(value) = node.eval(&ctx) else {
panic!("an inline completion is final on the spawning eval");
};
let rec = out_layout.rec(&value);
assert_eq!(unsafe { rec.element::<f64>() }, 6.);
assert_eq!(unsafe { rec.read::<f64>(opacity_offset) }, 0.25);
let GPoll::Final(value) = node.eval(&ctx) else {
panic!("a slot hit is final");
};
let rec = out_layout.rec(&value);
assert_eq!(unsafe { rec.element::<f64>() }, 6., "the slot hit replays the element");
assert_eq!(unsafe { rec.read::<f64>(opacity_offset) }, 0.25, "the fields re-carry on every eval");
}
#[test]
fn lazy_reads_bind_to_their_edge_and_leave_the_untaken_branch_unevaluated() {
let arena = Arena::new(1024).unwrap();
let generations = [];
let scope = scope_fixture(&generations, &arena);
let ctx = ContextImpl::root(&scope);
let unit = core_types::record::RecordLift::<(), _>::new(ValueNode(()));
let unit_layout = Node::<ContextImpl>::layout(&unit).unwrap().clone();
let content_layout = f64_layout(&["opacity"]);
reserve_for(&[&content_layout]);
let run = |opacity: Option<f64>| {
let evals = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
let alternate = core_types::record::RecordLift::<f64, _>::new(CountingValue(evals.clone()));
let alternate_layout = Node::<ContextImpl>::layout(&alternate).unwrap().clone();
let (content_layout, fields) = match opacity {
Some(value) => (content_layout.clone(), vec![(content_layout.offset_of("opacity", 0).unwrap(), value)]),
None => (f64_layout(&[]), vec![]),
};
let node = FallbackNode::new(
core_types::record::RecordLift::<(), _>::new(ValueNode(())),
f64_record_source(&content_layout, 7., fields),
alternate,
&unit_layout,
&content_layout,
&alternate_layout,
);
let GPoll::Final(value) = node.eval(&ctx) else {
panic!("expected a final record");
};
let element = unsafe { Node::<ContextImpl>::layout(&node).unwrap().rec(&value).element::<f64>() };
(element, evals.load(std::sync::atomic::Ordering::Relaxed))
};
assert_eq!(run(Some(0.5)), (7., 0), "a visible content skips the alternate branch entirely");
assert_eq!(run(Some(0.)), (21., 1), "a transparent content evaluates the alternate branch");
assert_eq!(run(None), (7., 0), "an absent attribute reads its declared default");
}
#[test]
fn remove_attr_leaves_the_layout_and_downstream_reads_the_default() {
let arena = Arena::new(1024).unwrap();
let generations = [];
let scope = scope_fixture(&generations, &arena);
let ctx = ContextImpl::root(&scope);
let source_layout = f64_layout(&[]);
let modified = multiply_opacity_layout(&source_layout);
let stripped = strip_opacity_layout(&modified);
assert!(stripped.offset_of(Opacity::NAME, 0).is_none(), "the removed name leaves the output layout");
let shaded = shade_layout(&stripped);
reserve_for(&[&source_layout, &modified, &stripped, &shaded]);
let chain = ShadeNode::new(
StripOpacityNode::new(MultiplyOpacityNode::new(bare_source(&source_layout, 4.), ValueNode(0.5), &source_layout), &modified),
&stripped,
);
let GPoll::Final(value) = chain.eval(&ctx) else {
panic!("expected a final record");
};
assert_eq!(unsafe { shaded.rec(&value).element::<f64>() }, 4., "a read after the removal yields the declared default");
}
#[test]
fn mixed_writes_and_removes_destructure_in_tuple_order() {
let arena = Arena::new(1024).unwrap();
let generations = [];
let scope = scope_fixture(&generations, &arena);
let ctx = ContextImpl::root(&scope);
let source_layout = f64_layout(&["opacity", "length"]);
let relengthed = relength_layout(&source_layout);
assert!(relengthed.offset_of(Opacity::NAME, 0).is_none());
reserve_for(&[&source_layout, &relengthed]);
let chain = RelengthNode::new(
f64_record_source(
&source_layout,
3.,
vec![(source_layout.offset_of("opacity", 0).unwrap(), 0.25), (source_layout.offset_of("length", 0).unwrap(), 9.)],
),
&source_layout,
);
let GPoll::Final(value) = chain.eval(&ctx) else {
panic!("expected a final record");
};
let rec = relengthed.rec(&value);
assert_eq!(unsafe { rec.element::<f64>() }, 3.);
assert_eq!(unsafe { rec.read::<f64>(relengthed.offset_of(Length::NAME, 0).unwrap()) }, 6.);
}
#[test]
fn parked_reference_attributes_write_and_carry() {
let arena = Arena::new(1024).unwrap();
@@ -479,11 +793,13 @@ mod tests {
reserve_for(&[&layout_a, &layout_b, &union, &union]);
let taken = |second: bool| {
let (condition, condition_layout) = lifted_value(second);
PickNode::new(
ValueNode(second),
condition,
RecordSource::new(f64_record_source(&layout_a, 1., vec![(layout_a.offset_of("opacity", 0).unwrap(), 0.5)]), &layout_a, &union),
RecordSource::new(f64_record_source(&layout_b, 3., vec![(layout_b.offset_of("length", 0).unwrap(), 3.)]), &layout_b, &union),
&union,
&condition_layout,
)
};
@@ -516,11 +832,13 @@ mod tests {
let union = Layout::union(&[&layout_a, &layout_b]);
reserve_for(&[&layout_a, &layout_b, &union, &union]);
let (condition, condition_layout) = lifted_value(false);
let chain = HoldFirstNode::new(
ValueNode(false),
condition,
RecordSource::new(f64_record_source(&layout_a, 1., vec![(layout_a.offset_of("opacity", 0).unwrap(), 0.5)]), &layout_a, &union),
RecordSource::new(f64_record_source(&layout_b, 3., vec![(layout_b.offset_of("length", 0).unwrap(), 3.)]), &layout_b, &union),
&union,
&condition_layout,
);
let GPoll::Final(value) = chain.eval(&ctx) else {
@@ -564,7 +882,8 @@ mod tests {
reserve_for(&[&layout]);
let probed = |features: ContextFeatures| {
let node = crate::context_modification::ContextModificationNode::new(RealTimeProbe { layout: layout.clone() }, ValueNode(ContextModification::from_sources(features, &[])), &layout);
let (modification, modification_layout) = lifted_value(ContextModification::from_sources(features, &[]));
let node = crate::context_modification::ContextModificationNode::new(RealTimeProbe { layout: layout.clone() }, modification, &layout, &modification_layout);
assert_eq!(Node::<ContextImpl>::layout(&node), Some(&layout));
let GPoll::Final(value) = node.eval(&ctx) else {
panic!("expected a final record");
@@ -588,6 +907,7 @@ mod tests {
let layout = f64_layout(&["opacity"]);
reserve_for(&[&layout]);
let (modification, modification_layout) = lifted_value(ContextModification::from_sources(ContextFeatures::all(), &[]));
let node = crate::context_modification::ContextModificationNode::new(
RecordSourceNode {
layout: layout.clone(),
@@ -595,8 +915,9 @@ mod tests {
fields: vec![(layout.offset_of("opacity", 0).unwrap(), 0.25)],
partial: true,
},
ValueNode(ContextModification::from_sources(ContextFeatures::all(), &[])),
modification,
&layout,
&modification_layout,
);
let GPoll::Partial(value) = node.eval(&ctx) else {
@@ -637,11 +958,13 @@ mod tests {
assert!(union.is_inline());
reserve_for(&[&layout_a, &layout_b, &union, &union]);
let (condition, condition_layout) = lifted_value(false);
let chain = HoldFirstNode::new(
ValueNode(false),
condition,
RecordSource::new(f64_record_source(&layout_a, 1., vec![(layout_a.offset_of("opacity", 0).unwrap(), 0.5)]), &layout_a, &union),
RecordSource::new(f64_record_source(&layout_b, 3., vec![]), &layout_b, &union),
&union,
&condition_layout,
);
let GPoll::Final(value) = chain.eval(&ctx) else {

View File

@@ -12,7 +12,7 @@ use wgpu::util::DeviceExt;
use wgpu_executor::{WgpuExecutor, WgpuPipeline, WgpuPipelineCache};
#[node_macro::node(category(""))]
fn render_background<'a>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, #[scope(composite_background_pipeline::IDENTIFIER)] pipeline: WgpuPipelineCache, data: RenderOutput) -> RenderOutput {
fn render_background(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, #[scope(composite_background_pipeline::IDENTIFIER)] pipeline: WgpuPipelineCache, data: RenderOutput) -> RenderOutput {
let footprint = ctx.footprint();
let render_params = ctx
.vararg(0)