diff --git a/Cargo.lock b/Cargo.lock index 338d3eb865..2021bdb780 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2937,6 +2937,7 @@ dependencies = [ "graph-craft", "graphene-core", "graphene-std", + "graphic-types", "gungraun", "log", "once_cell", diff --git a/node-graph/interpreted-executor/Cargo.toml b/node-graph/interpreted-executor/Cargo.toml index 271408240c..340ef3f87f 100644 --- a/node-graph/interpreted-executor/Cargo.toml +++ b/node-graph/interpreted-executor/Cargo.toml @@ -15,6 +15,7 @@ wasm = ["graphene-std/wasm"] graphene-std = { workspace = true } graph-craft = { workspace = true } graphene-core = { workspace = true } +graphic-types = { workspace = true } wgpu-executor = { workspace = true } core-types = { workspace = true } dyn-any = { workspace = true } diff --git a/node-graph/interpreted-executor/src/dynamic_executor.rs b/node-graph/interpreted-executor/src/dynamic_executor.rs index 9f2142ecbf..3ddccb5a2c 100644 --- a/node-graph/interpreted-executor/src/dynamic_executor.rs +++ b/node-graph/interpreted-executor/src/dynamic_executor.rs @@ -145,15 +145,16 @@ impl DynamicExecutor { } /// Calls the `Node::serialize` for that specific node, returning for example the cached value for a monitor node. The node path must match the document node path. - /// A monitor's record capture materializes its element here against the - /// arena, inside the introspection window, so consumers downcast the - /// element type directly. The captured input context stays on the - /// serialized io record for consumers that need it. + /// A monitor's record capture materializes here against the arena, inside + /// the introspection window, so consumers downcast the legacy value type + /// directly: a rank-0 capture yields its element and a level capture its + /// legacy list. The captured input context stays on the serialized io + /// record for consumers that need it. pub fn introspect(&self, node_path: &[NodeId]) -> Result, IntrospectError> { let result = self.tree.introspect(node_path)?; if let Some(io) = result.downcast_ref::>() { let arena = self.arena.lock().unwrap_or_else(PoisonError::into_inner); - return io.output.materialize_element(&arena).map(Arc::from).ok_or(IntrospectError::NoData); + return graphic_types::boundary::capture_to_legacy(&io.output, &arena).map(Arc::from).ok_or(IntrospectError::NoData); } Ok(result) } diff --git a/node-graph/libraries/core-types/src/record.rs b/node-graph/libraries/core-types/src/record.rs index 85af0baa5e..e61f92ad4d 100644 --- a/node-graph/libraries/core-types/src/record.rs +++ b/node-graph/libraries/core-types/src/record.rs @@ -606,6 +606,63 @@ where } } +/// The outcome of materializing a leveled edge's whole flat span. +pub enum LevelStatus<'a> { + Batch(crate::node::RecordBatch<'a>, crate::gpoll::Finality), + Pending, + Error(crate::gpoll::GraphError), +} + +/// Evaluates a leveled edge's whole flat span into one batch: an exact total +/// fills once, a lower bound drains by guess-and-double until a short fill, +/// each reply's hint seeding the next guess. The boundary consumers' driver; +/// reducers inline the same protocol with their span offsets. +pub fn materialize_level<'a, 'e, C, N>(node: &'a N, input: &'a C, arena: &'a crate::arena::Arena) -> LevelStatus<'a> +where + C: crate::context::InjectIndex + Copy, + N: Node>, +{ + use crate::gpoll::{Extent, GraphError, Level}; + use crate::node::BatchStatus; + let sized = match node.extent(input, Level::Total) { + GPoll::Final(Extent::Exactly(count)) => Ok(count), + GPoll::Final(Extent::AtLeast(bound)) => Err(bound), + GPoll::Pending => return LevelStatus::Pending, + _ => return LevelStatus::Error(GraphError::new("materialize over a non-exact extent")), + }; + match sized { + Ok(count) => match materialize_batch(node, input, 0..count as u64, arena) { + BatchStatus::Lent(batch, finality, _) => LevelStatus::Batch(batch, finality), + BatchStatus::Filled(batch, finality, _) => LevelStatus::Batch(batch.into_shared(), finality), + BatchStatus::Pending => LevelStatus::Pending, + BatchStatus::Error(error) => LevelStatus::Error(error), + _ => LevelStatus::Error(GraphError::new("materialize batch failed")), + }, + Err(bound) => { + let mut guess = bound.max(16); + loop { + let (batch, finality, hint) = match materialize_batch(node, input, 0..guess as u64, arena) { + BatchStatus::Lent(batch, finality, hint) => (batch, finality, hint), + BatchStatus::Filled(batch, finality, hint) => (batch.into_shared(), finality, hint), + BatchStatus::Pending => return LevelStatus::Pending, + BatchStatus::Error(error) => return LevelStatus::Error(error), + _ => return LevelStatus::Error(GraphError::new("materialize batch failed")), + }; + let filled = batch.len(); + if filled < guess { + break LevelStatus::Batch(batch, finality); + } + match hint { + Extent::Exactly(total) if total <= filled => break LevelStatus::Batch(batch, finality), + Extent::Exactly(total) => guess = total, + Extent::AtLeast(more) => guess = (guess * 2).max(more), + Extent::Free => guess *= 2, + } + } + } + } +} + /// A record edge at a caller-chosen lifetime; the lifetime is a trait /// parameter for the same constrained-position reason as /// [`DerivedRecordEdge`]. @@ -671,6 +728,16 @@ impl<'a, N> RecordEdgeInput<'a, N> { { self.node.eval(ctx) } + + /// [`materialize_level`] over the edge: the wire's whole flat span as one + /// batch. + pub fn materialize_level<'e, 'b, C>(&'b self, ctx: &'b C, arena: &'b crate::arena::Arena) -> LevelStatus<'b> + where + N: Node>, + C: crate::context::InjectIndex + Copy, + { + materialize_level(self.node, ctx, arena) + } } /// The raw lazy edge handed to a poll kernel whose wire rides records while @@ -1178,10 +1245,33 @@ pub fn element_dims() -> (usize, usize) { } } +/// Deep clone-out overrides for element types whose plain clone borrows the +/// evaluation's arena (a `Graphic` holding a group interior). The generic +/// element glue consults this registry, so every layout carrying such an +/// element deep-copies at memo and capture seams regardless of which +/// constructor built the glue. The override must produce a value of the +/// element's own type that owns all of its content, so the generic re-park +/// replays it unchanged. +static DEEP_ELEMENT_CLONES: std::sync::LazyLock Box>>> = + std::sync::LazyLock::new(Default::default); + +/// Registers `clone_out` as the deep clone-out for elements of `T`. Called at +/// startup from the crate that owns the type. +pub fn register_deep_element_clone(clone_out: unsafe fn(*const u8) -> Box) { + DEEP_ELEMENT_CLONES.lock().unwrap().insert(std::any::TypeId::of::(), clone_out); +} + +fn deep_element_clone(type_id: std::any::TypeId) -> Option Box> { + DEEP_ELEMENT_CLONES.lock().unwrap().get(&type_id).copied() +} + /// The element slot a record wire of `T` carries, its erased glue bound at /// the statically-known type. pub fn element_write() -> ElementWrite { unsafe fn clone_out(ptr: *const u8) -> Box { + if let Some(deep) = deep_element_clone(std::any::TypeId::of::()) { + return unsafe { deep(ptr) }; + } Box::new(unsafe { read_element::(Rec::new(ptr)) }) } unsafe fn repark(value: &(dyn std::any::Any + Send + Sync), dst: *mut u8, arena: &crate::arena::Arena) -> Option<()> { @@ -1398,6 +1488,7 @@ pub unsafe fn record_from_bytes<'e>(layout: &Layout, bytes: &'e [u8]) -> RecordV #[derive(Clone)] pub struct RecordCapture { layout: Layout, + lanes: usize, bytes: crate::arena::ArenaWeak>, } @@ -1412,17 +1503,65 @@ impl RecordCapture { /// `rec` must be a live record of `layout`. pub unsafe fn capture(layout: &Layout, rec: Rec, arena: &crate::arena::Arena) -> Option { let bytes = unsafe { copy_record_bytes(layout, rec) }; - arena.alloc(bytes).map(|(_, weak)| RecordCapture { layout: layout.clone(), bytes: weak }) + arena.alloc(bytes).map(|(_, weak)| RecordCapture { + layout: layout.clone(), + lanes: 1, + bytes: weak, + }) } - /// The captured element, cloned out through the layout's erased glue. + /// Captures every lane of a leveled wire's batch. + /// + /// # Safety + /// `batch` must hold live records of `layout`. + pub unsafe fn capture_level(layout: &Layout, batch: crate::node::RecordBatch<'_>, arena: &crate::arena::Arena) -> Option { + let stride = layout.lane_stride(); + let mut bytes = vec![0u8; batch.len() * stride].into_boxed_slice(); + for lane in 0..batch.len() { + // SAFETY: both sides hold `len` lanes at the shared layout's stride. + unsafe { std::ptr::copy_nonoverlapping(batch.get(lane).rec().ptr(), bytes.as_mut_ptr().add(lane * stride), stride) }; + } + arena.alloc(bytes).map(|(_, weak)| RecordCapture { + layout: layout.clone(), + lanes: batch.len(), + bytes: weak, + }) + } + + pub fn layout(&self) -> &Layout { + &self.layout + } + + /// The captured lane count: one for a rank-0 capture, the whole extent + /// for a level capture. + pub fn lanes(&self) -> usize { + self.lanes + } + + /// A batch view over the captured records, alive while the arena holds + /// the capture's generation. + pub fn batch<'a>(&'a self, arena: &'a crate::arena::Arena) -> Option> { + let bytes = self.bytes.upgrade(arena)?; + // SAFETY: the constructors store `lanes` records of `layout`. + Some(unsafe { crate::node::RecordBatch::new(bytes.as_ptr(), self.lanes, &self.layout) }) + } + + /// The captured element of the first lane, cloned out through the + /// layout's erased glue. pub fn materialize_element(&self, arena: &crate::arena::Arena) -> Option> { let bytes = self.bytes.upgrade(arena)?; - Some(unsafe { (self.layout.element.clone_out)(bytes.as_ptr()) }) + match self.lanes { + 0 => None, + _ => Some(unsafe { (self.layout.element.clone_out)(bytes.as_ptr()) }), + } } + /// The first lane's attributes, read out through the layout's erased glue. pub fn materialize(&self, arena: &crate::arena::Arena) -> Option)>> { let bytes = self.bytes.upgrade(arena)?; + if self.lanes == 0 { + return Some(Vec::new()); + } Some( self.layout .fields diff --git a/node-graph/libraries/core-types/src/value.rs b/node-graph/libraries/core-types/src/value.rs index 4fcd0f96cb..33025b8a02 100644 --- a/node-graph/libraries/core-types/src/value.rs +++ b/node-graph/libraries/core-types/src/value.rs @@ -57,11 +57,11 @@ pub struct LeveledValueSource { layout: crate::record::Layout, } -impl LeveledValueSource { +impl LeveledValueSource { pub fn new(values: Vec) -> Self { Self { values, - layout: crate::record::Layout::default().with_writes(1, crate::record::element_write::(), &[]), + layout: crate::record::Layout::default().with_writes(1, crate::record::element_write_hashed::(), &[]), } } } @@ -93,7 +93,7 @@ where } /// The native record edge of a constant level: the edge type is the element's. -pub fn leveled_record_value_edge(values: Vec) -> crate::registry::EdgeHandle { +pub fn leveled_record_value_edge(values: Vec) -> crate::registry::EdgeHandle { crate::registry::EdgeHandle::new_record::(std::sync::Arc::new(LeveledValueSource::new(values)) as std::sync::Arc) } diff --git a/node-graph/libraries/graphic-types/src/boundary.rs b/node-graph/libraries/graphic-types/src/boundary.rs new file mode 100644 index 0000000000..6bbc0b94d9 --- /dev/null +++ b/node-graph/libraries/graphic-types/src/boundary.rs @@ -0,0 +1,85 @@ +//! Boundary helpers between leveled wires and the legacy editor surface: +//! the renderer's flip form materializes a wire into a group, and captured +//! wires convert to the legacy values the editor's downcasts expect. + +use crate::graphic::{Graphic, group_to_legacy_list, run_to_legacy_list}; +use crate::raster_types::{CPU, GPU, Raster}; +use crate::{Artboard, Vector}; +use core_types::Color; +use core_types::arena::Arena; +use core_types::context::InjectIndex; +use core_types::gpoll::{Finality, GraphError}; +use core_types::node::Node; +use core_types::record::{Group, GroupContent, GroupItem, LevelStatus, RecordCapture, RecordValue, materialize_level}; +use core_types::uuid::NodeId; +use glam::{DAffine2, DVec2}; +use vector_types::GradientStops; + +/// The outcome of materializing a leveled wire into a group. +pub enum LevelGroup { + Group(Group, Finality), + Pending, + Error(GraphError), +} + +/// The renderer's flip form: the wire's whole extent materialized into a +/// group over the level's records, ready for the group render bridge. +pub fn materialize_group<'e, C, N>(node: &N, input: &C, arena: &Arena) -> LevelGroup +where + C: InjectIndex + Copy, + N: Node>, +{ + match materialize_level(node, input, arena) { + LevelStatus::Batch(batch, finality) => { + // SAFETY: a materialized batch's frames are arena-resident. + let item = unsafe { GroupItem::from_resident(batch) }; + LevelGroup::Group( + Group { + row: None, + content: GroupContent::Run(item), + }, + finality, + ) + } + LevelStatus::Pending => LevelGroup::Pending, + LevelStatus::Error(error) => LevelGroup::Error(error), + } +} + +/// The captured wire as the legacy value the editor's downcasts expect: a +/// rank-0 capture is its element, a leveled `Graphic` capture becomes its +/// legacy list through the group bridge, and another element type becomes a +/// legacy list of that element. `None` for an element type outside the +/// legacy vocabulary or a capture whose arena generation has passed. +pub fn capture_to_legacy(capture: &RecordCapture, arena: &Arena) -> Option> { + if capture.layout().depth == 0 { + return capture.materialize_element(arena); + } + let batch = capture.batch(arena)?; + // SAFETY: the captured bytes live in the arena for the capture's generation. + let item = unsafe { GroupItem::from_resident(batch) }; + fn typed(item: &GroupItem) -> Option> { + run_to_legacy_list::(item).map(|list| Box::new(list) as Box) + } + if item.typed_lanes::().is_some() { + let group = Group { + row: None, + content: GroupContent::Run(item), + }; + return Some(Box::new(group_to_legacy_list(&group))); + } + None.or_else(|| typed::(&item)) + .or_else(|| typed::(&item)) + .or_else(|| typed::>(&item)) + .or_else(|| typed::>(&item)) + .or_else(|| typed::(&item)) + .or_else(|| typed::(&item)) + .or_else(|| typed::(&item)) + .or_else(|| typed::(&item)) + .or_else(|| typed::(&item)) + .or_else(|| typed::(&item)) + .or_else(|| typed::(&item)) + .or_else(|| typed::(&item)) + .or_else(|| typed::(&item)) + .or_else(|| typed::(&item)) +} diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index c1ed51aef6..5e230dda2b 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -674,7 +674,7 @@ fn group_bounding_box(group: &core_types::record::Group, transform: DAffine2, in /// One typed run as a legacy list, elements cloned and every attribute /// copied through its erased read. -fn run_to_legacy_list(item: &core_types::record::GroupItem) -> Option> { +pub(crate) fn run_to_legacy_list(item: &core_types::record::GroupItem) -> Option> { let lanes = item.typed_lanes::()?; let mut list = List::new(); for lane in 0..lanes.len() { @@ -690,6 +690,32 @@ fn run_to_legacy_list(item: &core_types::recor Some(list) } +/// The deep clone-out for `Graphic` elements: a plain clone of a group +/// interior would carry frame pointers into the evaluation's arena, so memo +/// and capture seams copy out the legacy-converted form, which owns all of +/// its content. The generic re-park replays it as an ordinary `Graphic`. +/// +/// # Safety +/// `ptr` must point at a live parked `Graphic` element field. +unsafe fn deep_clone_graphic(ptr: *const u8) -> Box { + let graphic = unsafe { core_types::record::borrow_element::(core_types::record::Rec::new(ptr)) }; + Box::new(map_groups_to_legacy(graphic)) +} + +const _: () = { + #[cfg(not(target_family = "wasm"))] + #[core_types::ctor::ctor] + fn register() { + core_types::record::register_deep_element_clone::(deep_clone_graphic); + } + + #[cfg(target_family = "wasm")] + #[unsafe(export_name = "__node_registry_deep_element_graphic")] + extern "C" fn register() { + core_types::record::register_deep_element_clone::(deep_clone_graphic); + } +}; + /// The graphic with every `Group` converted to its legacy list form. pub fn map_groups_to_legacy(graphic: &Graphic) -> Graphic { match graphic { diff --git a/node-graph/libraries/graphic-types/src/lib.rs b/node-graph/libraries/graphic-types/src/lib.rs index 126e21d0bb..8d920b6a05 100644 --- a/node-graph/libraries/graphic-types/src/lib.rs +++ b/node-graph/libraries/graphic-types/src/lib.rs @@ -1,4 +1,5 @@ pub mod artboard; +pub mod boundary; pub mod graphic; pub mod markers; diff --git a/node-graph/nodes/gcore/src/memo.rs b/node-graph/nodes/gcore/src/memo.rs index facb096a6d..820c488cda 100644 --- a/node-graph/nodes/gcore/src/memo.rs +++ b/node-graph/nodes/gcore/src/memo.rs @@ -1,11 +1,11 @@ use core_types::arena::ArenaCell; -use core_types::context::{Ctx, CtxSnapshot, DeriveCtx, ExtractAll}; +use core_types::context::{Ctx, CtxSnapshot, DeriveCtx, ExtractAll, InjectIndex}; use core_types::extent::{ExtentIn, LevelIn}; use core_types::frame_table::{FrameTable, Lookup}; use core_types::gpoll::{Extent, Finality, GPoll}; use core_types::graphene_hash::CacheHash; use core_types::memo::IORecord; -use core_types::record::{OwnedRecord, RecordCapture, RecordValue, copy_record_bytes, record_from_bytes}; +use core_types::record::{LevelStatus, OwnedRecord, RecordCapture, RecordValue, copy_record_bytes, record_from_bytes}; use core_types::registry::cache_key; use std::sync::Arc; use std::sync::Mutex; @@ -100,11 +100,24 @@ type MonitorValue = Arc>>>; /// The Monitor node is used by the editor to access the data flowing through it. #[node_macro::node(category(""), path(graphene_core::memo), serialize(serialize_monitor), properties("monitor_properties"))] -fn monitor<'e>(ctx: impl Ctx + DeriveCtx + ExtractAll + ExtractArena<'e>, #[data] io: MonitorValue, content: impl Node, Output = RecordValue<'e>>) -> GPoll> { +fn monitor<'e>( + ctx: impl Ctx + DeriveCtx + ExtractAll + ExtractArena<'e> + InjectIndex + Copy, + #[data] io: MonitorValue, + content: impl Node, Output = RecordValue<'e>>, +) -> GPoll> { let result = content.eval(&ctx); if let GPoll::Final(value) | GPoll::Partial(value) = &result { - // SAFETY: the value came from this edge, so it carries the edge's layout. - let captured = unsafe { RecordCapture::capture(content.layout(), content.layout().rec(value), ctx.arena()) }; + let captured = match content.layout().depth { + // SAFETY: the value came from this edge, so it carries the edge's layout. + 0 => unsafe { RecordCapture::capture(content.layout(), content.layout().rec(value), ctx.arena()) }, + // A leveled wire captures its whole extent, not the one lane this + // context addresses. + _ => match content.materialize_level(ctx, ctx.arena()) { + // SAFETY: the batch came from this edge, so it carries the edge's layout. + LevelStatus::Batch(batch, _) => unsafe { RecordCapture::capture_level(content.layout(), batch, ctx.arena()) }, + LevelStatus::Pending | LevelStatus::Error(_) => None, + }, + }; *io.lock().unwrap() = captured.map(|output| IORecord { input: CtxSnapshot::capture(ctx), output, @@ -194,6 +207,55 @@ mod tests { assert_eq!(*element.downcast_ref::().unwrap(), 11); } + #[test] + fn a_leveled_monitor_captures_the_whole_extent() { + let arena = Arena::new(1 << 12).unwrap(); + let generations = []; + let scope = scope_fixture(&generations, &arena); + let ctx = ContextImpl::root(&scope); + + let source = core_types::value::LeveledValueSource::new(vec![10u32, 20, 30]); + let layout = Node::::layout(&source).clone(); + let monitor = MonitorNode::new(source, &layout); + let handle = EdgeHandle::new_record::(Arc::new(monitor) as Arc); + + let edge = handle.duplicate().downcast_record::().unwrap(); + let GPoll::Final(_) = edge.eval(&ctx) else { + panic!("expected a final record"); + }; + + let io = handle.serialize().expect("the eval landed a capture"); + let io = io.downcast_ref::>().expect("the capture is the monitor io"); + assert_eq!(io.output.lanes(), 3, "the capture holds the whole extent, not the addressed lane"); + let batch = io.output.batch(&arena).expect("the capture lives in this generation"); + let lanes = unsafe { core_types::node::List::::new(batch) }; + let values: Vec = (0..lanes.len()).map(|lane| *lanes.element_ref(lane)).collect(); + assert_eq!(values, vec![10, 20, 30]); + } + + #[test] + fn memo_copy_out_consults_the_deep_element_clone() { + #[derive(Clone, Debug, PartialEq)] + struct Payload(String, u32); + unsafe fn deep(ptr: *const u8) -> Box { + let value = unsafe { core_types::record::borrow_element::(core_types::record::Rec::new(ptr)) }; + Box::new(Payload(value.0.clone(), value.1 + 1)) + } + core_types::record::register_deep_element_clone::(deep); + + let arena = Arena::new(4096).unwrap(); + let generations = []; + let scope = scope_fixture(&generations, &arena); + let ctx = ContextImpl::root(&scope); + + let layout = element_layout::(); + let memoized = MemoizeNode::new(core_types::record::RecordLift::::new(ValueNode(Payload("deep".to_string(), 0))), &layout); + let memoized = core_types::record::RecordExtract::::new(memoized, &layout); + + assert_eq!(memoized.eval(&ctx), GPoll::Final(Payload("deep".to_string(), 0)), "the miss serves the live value"); + assert_eq!(memoized.eval(&ctx), GPoll::Final(Payload("deep".to_string(), 1)), "the hit replays the deep copy"); + } + #[test] fn memoize_caches_across_evals() { let arena = Arena::new(1024).unwrap(); diff --git a/node-graph/nodes/graphic/src/record.rs b/node-graph/nodes/graphic/src/record.rs index 3ddf051073..6c28e9e992 100644 --- a/node-graph/nodes/graphic/src/record.rs +++ b/node-graph/nodes/graphic/src/record.rs @@ -614,6 +614,43 @@ mod tests { unsafe { stack::rewind(mark) }; } + #[test] + fn a_wire_materializes_into_a_group_for_the_renderer() { + let arena = Arena::new(1 << 16).unwrap(); + let generations = []; + let scope = scope_fixture(&generations, &arena); + let ctx = ContextImpl::root(&scope); + + let source = core_types::value::LeveledValueSource::new(vec![text("a"), text("b")]); + match graphic_types::boundary::materialize_group(&source, &ctx, &arena) { + graphic_types::boundary::LevelGroup::Group(group, _) => { + let list = graphic_types::graphic::group_to_legacy_list(&group); + assert_eq!(list.len(), 2); + } + _ => panic!("expected a materialized group"), + } + } + + #[test] + fn a_level_capture_converts_to_its_legacy_list() { + let arena = Arena::new(1 << 16).unwrap(); + let generations = []; + let scope = scope_fixture(&generations, &arena); + let ctx = ContextImpl::root(&scope); + + let source = core_types::value::LeveledValueSource::new(vec![1.5f64, 2.5]); + let layout = Node::::layout(&source).clone(); + let record::LevelStatus::Batch(batch, _) = record::materialize_level(&source, &ctx, &arena) else { + panic!("expected a batch"); + }; + let capture = unsafe { record::RecordCapture::capture_level(&layout, batch, &arena) }.expect("the capture parks in the arena"); + let legacy = graphic_types::boundary::capture_to_legacy(&capture, &arena).expect("f64 is in the legacy vocabulary"); + let list = legacy.downcast_ref::>().unwrap(); + assert_eq!(list.len(), 2); + assert_eq!(list.element(0).copied(), Some(1.5)); + assert_eq!(list.element(1).copied(), Some(2.5)); + } + #[test] fn wrap_collects_the_level_into_a_group() { let arena = Arena::new(1 << 16).unwrap(); @@ -653,6 +690,35 @@ mod tests { } } + #[test] + fn a_group_element_deep_copies_to_its_legacy_form() { + let arena = Arena::new(1 << 16).unwrap(); + let generations = []; + let scope = scope_fixture(&generations, &arena); + let ctx = ContextImpl::root(&scope); + + let layout = graphic_layout(); + let rows = vec![(text("a"), translation(1.)), (text("b"), translation(2.))]; + let node = install( + WrapNode::new(RecordSource::new(GraphicSource { layout: layout.clone(), rows }, &layout, &layout), &layout), + wrap_layout_meta(), + &[Some(&layout)], + ); + let out = Node::::layout(&node).clone(); + + let head = ctx.index_head(); + let GPoll::Final(value) = node.eval(&ctx.promoted(&head, 0)) else { + panic!("expected a final record"); + }; + let copy = unsafe { (out.element.clone_out)(out.rec(&value).ptr()) }; + let Graphic::Graphic(list) = *copy.downcast::().expect("the deep copy replays at the element's own type") else { + panic!("expected the legacy-converted form"); + }; + assert_eq!(list.len(), 2); + assert_eq!(text_of(list.element(0).unwrap()), "a"); + assert_eq!(text_of(list.element(1).unwrap()), "b"); + } + #[test] fn colors_fold_into_evenly_spaced_stops() { struct ColorSource {