From bc29166d4ccf2ea9079cbcdc0c3890b3fe93ff54 Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Mon, 17 Aug 2026 09:26:59 +0000 Subject: [PATCH] Add the rank-model repeat on points flattening every points row --- node-graph/nodes/repeat/src/leveled.rs | 203 ++++++++++++++++++++++++- 1 file changed, 200 insertions(+), 3 deletions(-) diff --git a/node-graph/nodes/repeat/src/leveled.rs b/node-graph/nodes/repeat/src/leveled.rs index 6df60db012..c49776f512 100644 --- a/node-graph/nodes/repeat/src/leveled.rs +++ b/node-graph/nodes/repeat/src/leveled.rs @@ -5,11 +5,12 @@ use core::f64::consts::TAU; use core_types::attribute::{Attr, Transform}; use core_types::context::{ExtractIndex, IndexLink}; -use core_types::extent::{ExtentIn, LevelIn, ValueIn}; -use core_types::gpoll::{Extent, GPoll, Interrupt}; +use core_types::extent::{ExtentIn, LevelIn, ListIn, ValueIn}; +use core_types::gpoll::{Extent, GPoll, GraphError, Interrupt}; use core_types::registry::types::{Angle, PixelSize}; -use core_types::{Ctx, DeriveCtx}; +use core_types::{Ctx, DeriveCtx, InjectIndex}; use glam::{DAffine2, DVec2}; +use graphic_types::Vector; /// The rank-model Repeat Array: each copy evaluates the lazy content at its /// own index and composes the linear step onto the row's transform. @@ -83,6 +84,57 @@ fn repeat_radial_extent(content: ExtentIn<'_>, _start_angle: ValueIn<'_, Angle>, } } +/// The rank-model Repeat on Points: the pushed level flattens every point of +/// every points row, mirroring the eager iteration order (rows in order, a +/// row's points reversed when `reverse` is set); each copy evaluates the +/// content with its point's transformed position pushed, then lands the +/// content row's transform on that position. +#[node_macro::node(category("Test"), name("Repeat on Points"), extent(repeat_on_points_extent))] +fn repeat_on_points( + ctx: impl Ctx + DeriveCtx + ExtractIndex + InjectIndex + Copy, + content: impl Node, Output = (T, Attr)>, + points: IList, + reverse: bool, +) -> Result)>, Interrupt> { + let inner = content.inner_extent(ctx)?; + let (copy, rest) = ctx.split_innermost(inner); + + let mut remaining = copy as usize; + for row_index in 0..points.len() { + let vector = points.element_ref(row_index); + let positions = vector.point_domain.positions(); + if remaining >= positions.len() { + remaining -= positions.len(); + continue; + } + let index = match reverse { + true => positions.len() - 1 - remaining, + false => remaining, + }; + let transform: DAffine2 = points.lane(row_index).attr::(); + let transformed_point = transform.transform_point2(positions[index]); + + let scoped = ctx.push_position(transformed_point); + let mut frame = IndexLink { index: 0, outer: None }; + let (element, local) = content.eval(&scoped.ctx().push_level(&mut frame, index as u64, rest))?; + let mut composed = *local; + composed.translation = transformed_point; + return Ok(emit(element, Attr(composed))); + } + Err(GraphError::new("repeat on points addressed past its point count").into()) +} + +/// The pushed level's extent is the flattened point count across the points +/// rows; inner levels forward to the content, uniform across copies. +fn repeat_on_points_extent(content: ExtentIn<'_>, points: ListIn<'_, Vector>, _reverse: ValueIn<'_, bool>, level: LevelIn) -> GPoll { + match level.pushed() { + true => points + .get() + .map(|points| Extent::Exactly((0..points.len()).map(|row| points.element_ref(row).point_domain.positions().len()).sum())), + false => content.at(level), + } +} + #[cfg(test)] mod tests { use super::*; @@ -92,6 +144,7 @@ mod tests { use core_types::context::{ContextImpl, EvalScope}; use core_types::node::Node; use core_types::record::{FieldWrite, Layout, Rec, RecordSource, RecordValue, element_write, stack}; + use vector_types::subpath::Subpath; struct ValueNode(T); @@ -128,6 +181,67 @@ mod tests { EvalScope::new(Some(0.5), None, None, generations, arena) } + struct VectorRows { + layout: Layout, + rows: Vec<(Vector, DAffine2)>, + } + + impl<'e> Node> for VectorRows { + type Output = RecordValue<'e>; + + fn eval(&self, input: &ContextImpl<'e>) -> GPoll> { + use core_types::context::{ExtractArena, ExtractIndex}; + let (vector, transform) = &self.rows[input.innermost_index() as usize % self.rows.len()]; + let dst = stack::push(self.layout.frame_bytes()); + // SAFETY: dst is the claimed frame of this layout; offsets are the layout's own. + unsafe { + if core_types::record::write_element(dst, vector.clone(), input.arena()).is_none() { + return GPoll::Error(Box::new(core_types::gpoll::GraphError::new("arena exhausted"))); + } + core_types::record::write_field(dst, self.layout.offset_of(::NAME, 0).unwrap(), *transform); + } + stack::pop(dst); + GPoll::Final(RecordValue::spilled(unsafe { Rec::new(dst.cast_const()) })) + } + + fn extent_at(&self, _input: &ContextImpl<'e>, _level: u8) -> GPoll { + GPoll::Final(Extent::Exactly(self.rows.len())) + } + + fn layout(&self) -> &Layout { + &self.layout + } + } + + fn vector_rows_layout() -> Layout { + Layout::default().with_writes(1, element_write::(), &[FieldWrite::of::(0)]) + } + + struct PositionProbe { + layout: Layout, + } + + impl<'e> Node> for PositionProbe { + type Output = RecordValue<'e>; + + fn eval(&self, input: &ContextImpl<'e>) -> GPoll> { + use core_types::context::ExtractPosition; + let position = input.try_position().and_then(|mut positions| positions.next()).unwrap_or(DVec2::ZERO); + let dst = stack::push(self.layout.frame_bytes()); + // SAFETY: dst is the claimed frame of this layout; offsets are the layout's own. + unsafe { + dst.cast::().write(position.x); + core_types::record::write_field(dst, self.layout.offset_of(::NAME, 0).unwrap(), DAffine2::IDENTITY); + } + stack::pop(dst); + GPoll::Final(RecordValue::spilled(unsafe { Rec::new(dst.cast_const()) })) + } + + fn layout(&self) -> &Layout { + &self.layout + } + } + fn transform_layout() -> Layout { Layout::default().with_writes(0, element_write::(), &[FieldWrite::of::(0)]) } @@ -214,4 +328,87 @@ mod tests { unsafe { stack::rewind(mark) }; } } + #[test] + fn repeat_on_points_lands_each_copy_on_its_transformed_point() { + let arena = Arena::new(1 << 16).unwrap(); + let generations = []; + let scope = scope_fixture(&generations, &arena); + let ctx = ContextImpl::root(&scope); + + let row0: Vec = vec![DVec2::new(40., 20.), DVec2::ONE]; + let row1: Vec = vec![DVec2::new(-42., 9.), DVec2::new(10., 345.), DVec2::new(3., 4.)]; + let row0_transform = DAffine2::from_translation(DVec2::new(100., 0.)); + let points = VectorRows { + layout: vector_rows_layout(), + rows: vec![ + (Vector::from_subpath(Subpath::from_anchors(row0.clone(), false)), row0_transform), + (Vector::from_subpath(Subpath::from_anchors(row1.clone(), false)), DAffine2::IDENTITY), + ], + }; + let content_layout = transform_layout(); + let content = PositionProbe { layout: content_layout.clone() }; + + let mut node = RepeatOnPointsNode::new(RecordSource::new(content, &content_layout, &content_layout), points, ValueNode(false), &content_layout); + Node::::set_layout(&mut node, repeat_on_points_layout_meta().resolve(&[Some(&content_layout)])); + let leveled = Node::::layout(&node).clone(); + assert_eq!(leveled.depth, 1); + assert_eq!(node.extent_at(&ctx, 0), GPoll::Final(Extent::Exactly(5)), "the pushed level flattens both rows' points"); + + let expected: Vec = row0 + .iter() + .map(|&point| row0_transform.transform_point2(point)) + .chain(row1.iter().copied()) + .collect(); + + let head = ctx.index_head(); + for (flat, &point) in expected.iter().enumerate() { + let mark = stack::sp(); + let lane = ctx.promoted(&head, flat as u64); + let GPoll::Final(value) = node.eval(&lane) else { + panic!("expected a final record"); + }; + let rec = leveled.rec(&value); + // The content saw the pushed position, and the output transform lands on it. + assert_eq!(unsafe { rec.element::() }, point.x); + let composed: DAffine2 = unsafe { rec.read(leveled.offset_of(::NAME, 0).unwrap()) }; + assert_eq!(composed.translation, point); + // SAFETY: the element and transform were read out above, so no borrow into this lane's frames remains. + unsafe { stack::rewind(mark) }; + } + } + + #[test] + fn repeat_on_points_reverse_flips_each_rows_points() { + let arena = Arena::new(1 << 16).unwrap(); + let generations = []; + let scope = scope_fixture(&generations, &arena); + let ctx = ContextImpl::root(&scope); + + let positions: Vec = vec![DVec2::new(40., 20.), DVec2::ONE, DVec2::new(-42., 9.), DVec2::new(10., 345.)]; + let points = VectorRows { + layout: vector_rows_layout(), + rows: vec![(Vector::from_subpath(Subpath::from_anchors(positions.clone(), false)), DAffine2::IDENTITY)], + }; + let content_layout = transform_layout(); + let content = PositionProbe { layout: content_layout.clone() }; + + let mut node = RepeatOnPointsNode::new(RecordSource::new(content, &content_layout, &content_layout), points, ValueNode(true), &content_layout); + Node::::set_layout(&mut node, repeat_on_points_layout_meta().resolve(&[Some(&content_layout)])); + let leveled = Node::::layout(&node).clone(); + + let mut expected = positions.clone(); + expected.reverse(); + let head = ctx.index_head(); + for (flat, &point) in expected.iter().enumerate() { + let mark = stack::sp(); + let lane = ctx.promoted(&head, flat as u64); + let GPoll::Final(value) = node.eval(&lane) else { + panic!("expected a final record"); + }; + let composed: DAffine2 = unsafe { leveled.rec(&value).read(leveled.offset_of(::NAME, 0).unwrap()) }; + assert_eq!(composed.translation, point); + // SAFETY: the transform was read out above, so no borrow into this lane's frames remains. + unsafe { stack::rewind(mark) }; + } + } }