Add the leveled boundary helpers and the deep group element copy

This commit is contained in:
Dennis Kobert
2026-08-22 11:48:26 +00:00
parent 8eaa541c8c
commit 5821816228
10 changed files with 399 additions and 17 deletions

View File

@@ -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 }

View File

@@ -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<Arc<dyn std::any::Any + Send + Sync + 'static>, IntrospectError> {
let result = self.tree.introspect(node_path)?;
if let Some(io) = result.downcast_ref::<core_types::memo::IORecord<core_types::context::CtxSnapshot, core_types::record::RecordCapture>>() {
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)
}

View File

@@ -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<C, Output = RecordValue<'e>>,
{
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, Output = RecordValue<'e>>,
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<T>() -> (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<std::sync::Mutex<std::collections::HashMap<std::any::TypeId, unsafe fn(*const u8) -> Box<dyn std::any::Any + Send + Sync>>>> =
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<T: 'static>(clone_out: unsafe fn(*const u8) -> Box<dyn std::any::Any + Send + Sync>) {
DEEP_ELEMENT_CLONES.lock().unwrap().insert(std::any::TypeId::of::<T>(), clone_out);
}
fn deep_element_clone(type_id: std::any::TypeId) -> Option<unsafe fn(*const u8) -> Box<dyn std::any::Any + Send + Sync>> {
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<T: Clone + Send + Sync + 'static>() -> ElementWrite {
unsafe fn clone_out<T: Clone + Send + Sync + 'static>(ptr: *const u8) -> Box<dyn std::any::Any + Send + Sync> {
if let Some(deep) = deep_element_clone(std::any::TypeId::of::<T>()) {
return unsafe { deep(ptr) };
}
Box::new(unsafe { read_element::<T>(Rec::new(ptr)) })
}
unsafe fn repark<T: Clone + Send + Sync + 'static>(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<Box<[u8]>>,
}
@@ -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<RecordCapture> {
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<RecordCapture> {
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<crate::node::RecordBatch<'a>> {
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<Box<dyn std::any::Any + Send + Sync>> {
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<Vec<(&'static str, Box<dyn crate::list::AnyAttributeValue>)>> {
let bytes = self.bytes.upgrade(arena)?;
if self.lanes == 0 {
return Some(Vec::new());
}
Some(
self.layout
.fields

View File

@@ -57,11 +57,11 @@ pub struct LeveledValueSource<T> {
layout: crate::record::Layout,
}
impl<T: Clone + Send + Sync + 'static> LeveledValueSource<T> {
impl<T: Clone + Send + Sync + crate::CacheHash + PartialEq + 'static> LeveledValueSource<T> {
pub fn new(values: Vec<T>) -> Self {
Self {
values,
layout: crate::record::Layout::default().with_writes(1, crate::record::element_write::<T>(), &[]),
layout: crate::record::Layout::default().with_writes(1, crate::record::element_write_hashed::<T>(), &[]),
}
}
}
@@ -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<T: Clone + Send + Sync + 'static>(values: Vec<T>) -> crate::registry::EdgeHandle {
pub fn leveled_record_value_edge<T: Clone + Send + Sync + crate::CacheHash + PartialEq + 'static>(values: Vec<T>) -> crate::registry::EdgeHandle {
crate::registry::EdgeHandle::new_record::<T>(std::sync::Arc::new(LeveledValueSource::new(values)) as std::sync::Arc<crate::registry::ErasedRecordNode>)
}

View File

@@ -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<C, Output = RecordValue<'e>>,
{
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<Box<dyn std::any::Any + Send + Sync>> {
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<T: Clone + Send + Sync + 'static>(item: &GroupItem) -> Option<Box<dyn std::any::Any + Send + Sync>> {
run_to_legacy_list::<T>(item).map(|list| Box::new(list) as Box<dyn std::any::Any + Send + Sync>)
}
if item.typed_lanes::<Graphic>().is_some() {
let group = Group {
row: None,
content: GroupContent::Run(item),
};
return Some(Box::new(group_to_legacy_list(&group)));
}
None.or_else(|| typed::<Artboard>(&item))
.or_else(|| typed::<Vector>(&item))
.or_else(|| typed::<Raster<CPU>>(&item))
.or_else(|| typed::<Raster<GPU>>(&item))
.or_else(|| typed::<Color>(&item))
.or_else(|| typed::<GradientStops>(&item))
.or_else(|| typed::<String>(&item))
.or_else(|| typed::<f64>(&item))
.or_else(|| typed::<u64>(&item))
.or_else(|| typed::<u32>(&item))
.or_else(|| typed::<bool>(&item))
.or_else(|| typed::<NodeId>(&item))
.or_else(|| typed::<DAffine2>(&item))
.or_else(|| typed::<DVec2>(&item))
}

View File

@@ -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<T: Clone + Send + Sync + 'static>(item: &core_types::record::GroupItem) -> Option<List<T>> {
pub(crate) fn run_to_legacy_list<T: Clone + Send + Sync + 'static>(item: &core_types::record::GroupItem) -> Option<List<T>> {
let lanes = item.typed_lanes::<T>()?;
let mut list = List::new();
for lane in 0..lanes.len() {
@@ -690,6 +690,32 @@ fn run_to_legacy_list<T: Clone + Send + Sync + 'static>(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<dyn std::any::Any + Send + Sync> {
let graphic = unsafe { core_types::record::borrow_element::<Graphic>(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::<Graphic>(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::<Graphic>(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 {

View File

@@ -1,4 +1,5 @@
pub mod artboard;
pub mod boundary;
pub mod graphic;
pub mod markers;

View File

@@ -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<Mutex<Option<IORecord<CtxSnapshot, RecordCapture>>>>;
/// 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<Context<'_>, Output = RecordValue<'e>>) -> GPoll<RecordValue<'e>> {
fn monitor<'e>(
ctx: impl Ctx + DeriveCtx + ExtractAll + ExtractArena<'e> + InjectIndex + Copy,
#[data] io: MonitorValue,
content: impl Node<Context<'_>, Output = RecordValue<'e>>,
) -> GPoll<RecordValue<'e>> {
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::<u32>().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::<ContextImpl>::layout(&source).clone();
let monitor = MonitorNode::new(source, &layout);
let handle = EdgeHandle::new_record::<u32>(Arc::new(monitor) as Arc<ErasedRecordNode>);
let edge = handle.duplicate().downcast_record::<u32>().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::<IORecord<CtxSnapshot, RecordCapture>>().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::<u32>::new(batch) };
let values: Vec<u32> = (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<dyn std::any::Any + Send + Sync> {
let value = unsafe { core_types::record::borrow_element::<Payload>(core_types::record::Rec::new(ptr)) };
Box::new(Payload(value.0.clone(), value.1 + 1))
}
core_types::record::register_deep_element_clone::<Payload>(deep);
let arena = Arena::new(4096).unwrap();
let generations = [];
let scope = scope_fixture(&generations, &arena);
let ctx = ContextImpl::root(&scope);
let layout = element_layout::<Payload>();
let memoized = MemoizeNode::new(core_types::record::RecordLift::<Payload, _>::new(ValueNode(Payload("deep".to_string(), 0))), &layout);
let memoized = core_types::record::RecordExtract::<Payload, _>::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();

View File

@@ -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::<ContextImpl>::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::<List<f64>>().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::<ContextImpl>::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::<Graphic>().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 {