mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-10-04 21:48:12 +08:00
New nodes: Sort, Filter, Reverse, Shift, Shuffle, Number Sequence, List Indices, List Slice, Read Number (#4347)
This commit is contained in:
Generated
+1
@@ -2181,6 +2181,7 @@ dependencies = [
|
|||||||
"glam",
|
"glam",
|
||||||
"graphic-types",
|
"graphic-types",
|
||||||
"node-macro",
|
"node-macro",
|
||||||
|
"rand",
|
||||||
"raster-types",
|
"raster-types",
|
||||||
"serde",
|
"serde",
|
||||||
"vector-types",
|
"vector-types",
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[
|
|||||||
"graphene_core::transform_nodes::FreezeRealTimeNode",
|
"graphene_core::transform_nodes::FreezeRealTimeNode",
|
||||||
"graphene_core::vector::SubpathSegmentLengthsNode",
|
"graphene_core::vector::SubpathSegmentLengthsNode",
|
||||||
"core_types::vector::SubpathSegmentLengthsNode",
|
"core_types::vector::SubpathSegmentLengthsNode",
|
||||||
// The deleted debug Option trio degrades to a passthrough of its single input (audit resolution 8)
|
// The deleted debug Option trio degrades to a passthrough of its single input
|
||||||
"graphene_core::ops::SizeOfNode",
|
"graphene_core::ops::SizeOfNode",
|
||||||
"graphene_core::debug::SizeOfNode",
|
"graphene_core::debug::SizeOfNode",
|
||||||
"graphene_core::ops::SomeNode",
|
"graphene_core::ops::SomeNode",
|
||||||
@@ -2765,7 +2765,7 @@ fn migrate_removed_catalog_definitions(node_id: &NodeId, node: &DocumentNode, ne
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The removed Attach Attribute node (merged into Write Attribute per audit resolution 6) degrades to a passthrough of its
|
// The removed Attach Attribute node degrades to a passthrough of its
|
||||||
// content: its eager whole-list source input cannot be mechanically rewired as Write Attribute's lazy per-item value producer.
|
// content: its eager whole-list source input cannot be mechanically rewired as Write Attribute's lazy per-item value producer.
|
||||||
if let Some(DefinitionIdentifier::ProtoNode(identifier)) = document.network_interface.reference(node_id, network_path)
|
if let Some(DefinitionIdentifier::ProtoNode(identifier)) = document.network_interface.reference(node_id, network_path)
|
||||||
&& identifier.as_str().ends_with("::AttachAttributeNode")
|
&& identifier.as_str().ends_with("::AttachAttributeNode")
|
||||||
|
|||||||
@@ -346,6 +346,50 @@ fn position_value_converts_through_the_vector_input_adapter() {
|
|||||||
assert!(result.is_some(), "The position should arrive as an Item<Vector> single-anchor path");
|
assert!(result.is_some(), "The position should arrive as an Item<Vector> single-anchor path");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A scalar wire feeding a `DVec2` connector splats into both axes through the input adapter's `Convert` row
|
||||||
|
#[test]
|
||||||
|
fn number_value_splats_through_the_vec2_input_adapter() {
|
||||||
|
let number_node = ProtoNode::value(ConstructionArgs::Value(TaggedValue::F64(-60.).into()), vec![NodeId(0)]);
|
||||||
|
|
||||||
|
let mut input_adapter_node = ProtoNode::value(ConstructionArgs::Nodes(vec![NodeId(0)]), vec![NodeId(1)]);
|
||||||
|
input_adapter_node.identifier = ProtoNodeIdentifier::new("input_adapter<DVec2>");
|
||||||
|
|
||||||
|
let network = ProtoNetwork {
|
||||||
|
inputs: vec![],
|
||||||
|
output: NodeId(1),
|
||||||
|
nodes: vec![(NodeId(0), number_node), (NodeId(1), input_adapter_node)],
|
||||||
|
};
|
||||||
|
let mut typing_context = TypingContext::new(&crate::node_registry::NODE_REGISTRY);
|
||||||
|
typing_context.update(&network).expect("An f64 wire should resolve the adapter's splat conversion row");
|
||||||
|
let tree = futures::executor::block_on(BorrowTree::new(network, &typing_context)).expect("The splat constructor should instantiate");
|
||||||
|
|
||||||
|
let context: Context = None;
|
||||||
|
let result: Option<Item<glam::DVec2>> = futures::executor::block_on(tree.eval(NodeId(1), context));
|
||||||
|
assert_eq!(result.map(|item| *item.element()), Some(glam::DVec2::splat(-60.)), "The scalar should splat into both axes");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A scalar wire feeding a `String` connector formats as text through the input adapter's `Convert` row
|
||||||
|
#[test]
|
||||||
|
fn number_value_formats_through_the_string_input_adapter() {
|
||||||
|
let number_node = ProtoNode::value(ConstructionArgs::Value(TaggedValue::F64(42.).into()), vec![NodeId(0)]);
|
||||||
|
|
||||||
|
let mut input_adapter_node = ProtoNode::value(ConstructionArgs::Nodes(vec![NodeId(0)]), vec![NodeId(1)]);
|
||||||
|
input_adapter_node.identifier = ProtoNodeIdentifier::new("input_adapter<String>");
|
||||||
|
|
||||||
|
let network = ProtoNetwork {
|
||||||
|
inputs: vec![],
|
||||||
|
output: NodeId(1),
|
||||||
|
nodes: vec![(NodeId(0), number_node), (NodeId(1), input_adapter_node)],
|
||||||
|
};
|
||||||
|
let mut typing_context = TypingContext::new(&crate::node_registry::NODE_REGISTRY);
|
||||||
|
typing_context.update(&network).expect("An f64 wire should resolve the adapter's formatting conversion row");
|
||||||
|
let tree = futures::executor::block_on(BorrowTree::new(network, &typing_context)).expect("The formatting constructor should instantiate");
|
||||||
|
|
||||||
|
let context: Context = None;
|
||||||
|
let result: Option<Item<String>> = futures::executor::block_on(tree.eval(NodeId(1), context));
|
||||||
|
assert_eq!(result.map(|item| item.element().clone()), Some("42".to_string()), "The number should format as its text representation");
|
||||||
|
}
|
||||||
|
|
||||||
// A `List` wire feeding a `ListDyn` connector erases its element type through the input adapter's `Into` row
|
// A `List` wire feeding a `ListDyn` connector erases its element type through the input adapter's `Into` row
|
||||||
#[test]
|
#[test]
|
||||||
fn list_wire_erases_through_the_list_dyn_input_adapter() {
|
fn list_wire_erases_through_the_list_dyn_input_adapter() {
|
||||||
|
|||||||
@@ -411,7 +411,10 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
|
|||||||
Raster<CPU>,
|
Raster<CPU>,
|
||||||
Color,
|
Color,
|
||||||
Gradient,
|
Gradient,
|
||||||
|
f32,
|
||||||
f64,
|
f64,
|
||||||
|
u32,
|
||||||
|
u64,
|
||||||
bool,
|
bool,
|
||||||
String,
|
String,
|
||||||
DVec2,
|
DVec2,
|
||||||
@@ -485,8 +488,8 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
|
|||||||
node_types.extend(input_adapter_row!(from_element: String, element: BoxCorners));
|
node_types.extend(input_adapter_row!(from_element: String, element: BoxCorners));
|
||||||
// A number wire may feed the ranked `Item<BoxCorners>` connector, each number becoming a uniform radius for all four corners
|
// A number wire may feed the ranked `Item<BoxCorners>` connector, each number becoming a uniform radius for all four corners
|
||||||
node_types.extend(input_adapter_row!(from_element: f64, element: BoxCorners));
|
node_types.extend(input_adapter_row!(from_element: f64, element: BoxCorners));
|
||||||
// Numeric wires cast between element types at a ranked connector, as `Convert` does for bare numeric wires
|
// The `Convert`-based counterpart of `input_adapter_row!`, for casts the std `Into` trait cannot express
|
||||||
macro_rules! numeric_convert_node {
|
macro_rules! convert_adapter_node {
|
||||||
(from_element: $from:ty, element: $element:ty) => {{
|
(from_element: $from:ty, element: $element:ty) => {{
|
||||||
let entries: Vec<(ProtoNodeIdentifier, NodeConstructor, NodeIOTypes)> = vec![
|
let entries: Vec<(ProtoNodeIdentifier, NodeConstructor, NodeIOTypes)> = vec![
|
||||||
input_adapter_row!(node: ConvertItemNode, from: Item<$from>, to: Item<$element>, element: $element),
|
input_adapter_row!(node: ConvertItemNode, from: Item<$from>, to: Item<$element>, element: $element),
|
||||||
@@ -495,19 +498,24 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
|
|||||||
entries
|
entries
|
||||||
}};
|
}};
|
||||||
}
|
}
|
||||||
macro_rules! numeric_convert_star {
|
macro_rules! convert_adapter_wildcard {
|
||||||
(from: $from:ty, to: [$($to:ty),*]) => {{
|
(from: $from:ty, to: [$($to:ty),*]) => {{
|
||||||
let mut entries: Vec<(ProtoNodeIdentifier, NodeConstructor, NodeIOTypes)> = Vec::new();
|
let mut entries: Vec<(ProtoNodeIdentifier, NodeConstructor, NodeIOTypes)> = Vec::new();
|
||||||
$(entries.extend(numeric_convert_node!(from_element: $from, element: $to));)*
|
$(entries.extend(convert_adapter_node!(from_element: $from, element: $to));)*
|
||||||
entries
|
entries
|
||||||
}};
|
}};
|
||||||
}
|
}
|
||||||
node_types.extend(numeric_convert_star!(from: f64, to: [f32, u32, u64, i32, i64]));
|
// Numeric wires cast between numeric element types, splat to fill both axes of a `DVec2` connector, and format into a `String` connector
|
||||||
node_types.extend(numeric_convert_star!(from: f32, to: [f64, u32, u64, i32, i64]));
|
node_types.extend(convert_adapter_wildcard!(from: f64, to: [f32, u32, u64, i32, i64, DVec2, String]));
|
||||||
node_types.extend(numeric_convert_star!(from: u32, to: [f64, f32, u64, i32, i64]));
|
node_types.extend(convert_adapter_wildcard!(from: f32, to: [f64, u32, u64, i32, i64, DVec2, String]));
|
||||||
node_types.extend(numeric_convert_star!(from: u64, to: [f64, f32, u32, i32, i64]));
|
node_types.extend(convert_adapter_wildcard!(from: u32, to: [f64, f32, u64, i32, i64, DVec2, String]));
|
||||||
node_types.extend(numeric_convert_star!(from: i32, to: [f64, f32, u32, u64, i64]));
|
node_types.extend(convert_adapter_wildcard!(from: u64, to: [f64, f32, u32, i32, i64, DVec2, String]));
|
||||||
node_types.extend(numeric_convert_star!(from: i64, to: [f64, f32, u32, u64, i32]));
|
node_types.extend(convert_adapter_wildcard!(from: i32, to: [f64, f32, u32, u64, i64, DVec2, String]));
|
||||||
|
node_types.extend(convert_adapter_wildcard!(from: i64, to: [f64, f32, u32, u64, i32, DVec2, String]));
|
||||||
|
// Bool, position, and transform wires may feed a ranked `String` connector by formatting each element as text
|
||||||
|
node_types.extend(convert_adapter_node!(from_element: bool, element: String));
|
||||||
|
node_types.extend(convert_adapter_node!(from_element: DVec2, element: String));
|
||||||
|
node_types.extend(convert_adapter_node!(from_element: DAffine2, element: String));
|
||||||
// The sanctioned attribute value conversions: an Item wire's elements box per cell, while a List wire boxes whole as one value
|
// The sanctioned attribute value conversions: an Item wire's elements box per cell, while a List wire boxes whole as one value
|
||||||
macro_rules! attribute_value_node {
|
macro_rules! attribute_value_node {
|
||||||
(Item<$element:ty>) => {
|
(Item<$element:ty>) => {
|
||||||
|
|||||||
@@ -45,6 +45,14 @@ pub trait Convert<T, C>: Sized {
|
|||||||
fn convert(self, footprint: Footprint, converter: C) -> impl Future<Output = T> + Send;
|
fn convert(self, footprint: Footprint, converter: C) -> impl Future<Output = T> + Send;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<T: ToString + Send> Convert<String, ()> for T {
|
||||||
|
/// Converts this type into a `String` using its `ToString` implementation.
|
||||||
|
#[inline]
|
||||||
|
async fn convert(self, _: Footprint, _converter: ()) -> String {
|
||||||
|
self.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Constructs `Self` from a single anchor point at the given position. Implemented by the vector crate's
|
/// Constructs `Self` from a single anchor point at the given position. Implemented by the vector crate's
|
||||||
/// path type so a position wire can convert to a single-point path without core-types depending on that crate.
|
/// path type so a position wire can convert to a single-point path without core-types depending on that crate.
|
||||||
pub trait FromAnchorPosition {
|
pub trait FromAnchorPosition {
|
||||||
|
|||||||
@@ -46,6 +46,33 @@ fn read_gradient(ctx: impl Ctx + ExtractVarArgs) -> Item<Gradient> {
|
|||||||
var_arg.downcast_ref().cloned().unwrap_or_default()
|
var_arg.downcast_ref().cloned().unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reads the current number from within a **Map** node's loop.
|
||||||
|
#[node_macro::node(category("Context"))]
|
||||||
|
fn read_number(ctx: impl Ctx + ExtractVarArgs) -> Item<f64> {
|
||||||
|
let Ok(var_arg) = ctx.vararg(0) else { return Default::default() };
|
||||||
|
let var_arg = var_arg as &dyn std::any::Any;
|
||||||
|
|
||||||
|
if let Some(item) = var_arg.downcast_ref::<Item<f64>>() {
|
||||||
|
return item.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numeric lists carry several possible element types, so probe each and widen to f64, keeping the item's attributes
|
||||||
|
if let Some(item) = var_arg.downcast_ref::<Item<f32>>() {
|
||||||
|
let (element, attributes) = item.clone().into_parts();
|
||||||
|
return Item::from_parts(element as f64, attributes);
|
||||||
|
}
|
||||||
|
if let Some(item) = var_arg.downcast_ref::<Item<u32>>() {
|
||||||
|
let (element, attributes) = item.clone().into_parts();
|
||||||
|
return Item::from_parts(element as f64, attributes);
|
||||||
|
}
|
||||||
|
if let Some(item) = var_arg.downcast_ref::<Item<u64>>() {
|
||||||
|
let (element, attributes) = item.clone().into_parts();
|
||||||
|
return Item::from_parts(element as f64, attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
Default::default()
|
||||||
|
}
|
||||||
|
|
||||||
#[node_macro::node(category("Context"), path(core_types::vector))]
|
#[node_macro::node(category("Context"), path(core_types::vector))]
|
||||||
async fn read_position(
|
async fn read_position(
|
||||||
ctx: impl Ctx + ExtractPosition,
|
ctx: impl Ctx + ExtractPosition,
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ dyn-any = { workspace = true }
|
|||||||
glam = { workspace = true }
|
glam = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
node-macro = { workspace = true }
|
node-macro = { workspace = true }
|
||||||
|
rand = { workspace = true }
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
use core_types::bounds::{BoundingBox, RenderBoundingBox};
|
use core_types::bounds::{BoundingBox, RenderBoundingBox};
|
||||||
use core_types::list::{AttributeValueDyn, Item, List, ListDyn, NodeIdPath};
|
use core_types::list::{AttributeValueDyn, Item, List, ListDyn, NodeIdPath};
|
||||||
use core_types::registry::types::{Angle, SignedInteger};
|
use core_types::registry::types::{Angle, SeedValue, SignedInteger};
|
||||||
use core_types::{ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_TRANSFORM, AnyHash, BlendMode, CacheHash, CloneVarArgs, Color, Context, Ctx, ExtractAll, OwnedContextImpl};
|
use core_types::{ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_TRANSFORM, AnyHash, BlendMode, CacheHash, CloneVarArgs, Color, Context, Ctx, ExtractAll, OwnedContextImpl};
|
||||||
use glam::{DAffine2, DVec2};
|
use glam::{DAffine2, DVec2};
|
||||||
use graphic_types::graphic::{Graphic, IntoGraphicList};
|
use graphic_types::graphic::{Graphic, IntoGraphicList};
|
||||||
use graphic_types::{Artboard, Vector};
|
use graphic_types::{Artboard, Vector};
|
||||||
|
use rand::SeedableRng;
|
||||||
|
use rand::seq::SliceRandom;
|
||||||
use raster_types::{CPU, GPU, Raster};
|
use raster_types::{CPU, GPU, Raster};
|
||||||
|
use std::cmp::Ordering;
|
||||||
use vector_types::gradient::{GradientSpreadMethod, GradientType};
|
use vector_types::gradient::{GradientSpreadMethod, GradientType};
|
||||||
use vector_types::{Gradient, GradientStop, ReferencePoint};
|
use vector_types::{Gradient, GradientStop, ReferencePoint};
|
||||||
|
|
||||||
@@ -45,12 +48,12 @@ pub fn remove_at_index<T: graphic_types::graphic::OmitIndex + Clone + Default>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the item at the specified index in a `List`, keeping its attributes.
|
/// Returns the item at the specified index in a list, keeping its attributes.
|
||||||
/// If no value exists at that index, the element type's default is returned.
|
/// If no value exists at that index, the element type's default is returned.
|
||||||
#[node_macro::node(category("General"), name("Item at Index"))]
|
#[node_macro::node(category("General"), name("Item at Index"))]
|
||||||
pub fn item_at_index<T: Clone + Default + Send + Sync + 'static>(
|
pub fn item_at_index<T: Clone + Default + Send + Sync + 'static>(
|
||||||
_: impl Ctx,
|
_: impl Ctx,
|
||||||
/// The `List` of data to take the item from.
|
/// The list of data to take the item from.
|
||||||
#[implementations(
|
#[implementations(
|
||||||
List<String>,
|
List<String>,
|
||||||
List<bool>,
|
List<bool>,
|
||||||
@@ -85,6 +88,328 @@ pub fn item_at_index<T: Clone + Default + Send + Sync + 'static>(
|
|||||||
list.clone_item(resolved).unwrap_or_default()
|
list.clone_item(resolved).unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keeps chosen items from a list (those corresponding to `true` values) and discards the others (those corresponding to `false` values) based on the *Keep Pattern* bool list. A short pattern is repeated over the remainder of the filtered list, allowing a pattern like `[true, false]` to keep every other item starting from the first. An empty pattern keeps all items.
|
||||||
|
#[node_macro::node(category("General"))]
|
||||||
|
fn filter<T: Send + Sync + 'static>(
|
||||||
|
_: impl Ctx,
|
||||||
|
/// The list of data to filter.
|
||||||
|
#[implementations(
|
||||||
|
List<String>,
|
||||||
|
List<bool>,
|
||||||
|
List<f32>,
|
||||||
|
List<f64>,
|
||||||
|
List<u32>,
|
||||||
|
List<u64>,
|
||||||
|
List<DVec2>,
|
||||||
|
List<DAffine2>,
|
||||||
|
List<Vector>,
|
||||||
|
List<Graphic>,
|
||||||
|
List<Raster<CPU>>,
|
||||||
|
List<Raster<GPU>>,
|
||||||
|
List<Color>,
|
||||||
|
List<Gradient>,
|
||||||
|
List<Artboard>,
|
||||||
|
)]
|
||||||
|
list: List<T>,
|
||||||
|
/// The list of true and false values that determines which corresponding items are kept (`true`) and discarded (`false`). The pattern may repeat if it is shorter than the list of data.
|
||||||
|
keep_pattern: List<bool>,
|
||||||
|
) -> List<T> {
|
||||||
|
// Tile the keep pattern over the items, so a short pattern repeats from the start
|
||||||
|
let pattern = keep_pattern.iter_element_values().as_slice();
|
||||||
|
if pattern.is_empty() {
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.into_iter().enumerate().filter_map(|(index, item)| pattern[index % pattern.len()].then_some(item)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reverses the order of the items in a list, so the last item comes first and the first comes last.
|
||||||
|
#[node_macro::node(category("General"))]
|
||||||
|
fn reverse<T: Send + Sync + 'static>(
|
||||||
|
_: impl Ctx,
|
||||||
|
/// The list of data to reverse.
|
||||||
|
#[implementations(
|
||||||
|
List<String>,
|
||||||
|
List<bool>,
|
||||||
|
List<f32>,
|
||||||
|
List<f64>,
|
||||||
|
List<u32>,
|
||||||
|
List<u64>,
|
||||||
|
List<DVec2>,
|
||||||
|
List<DAffine2>,
|
||||||
|
List<Vector>,
|
||||||
|
List<Graphic>,
|
||||||
|
List<Raster<CPU>>,
|
||||||
|
List<Raster<GPU>>,
|
||||||
|
List<Color>,
|
||||||
|
List<Gradient>,
|
||||||
|
List<Artboard>,
|
||||||
|
)]
|
||||||
|
list: List<T>,
|
||||||
|
) -> List<T> {
|
||||||
|
list.into_iter().rev().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shifts the items in a list by a number of positions. With wrapping, items pushed off one end reappear at the other. Otherwise they are dropped, shortening the list.
|
||||||
|
#[node_macro::node(category("General"))]
|
||||||
|
fn shift<T: Send + Sync + 'static>(
|
||||||
|
_: impl Ctx,
|
||||||
|
/// The list of data to shift.
|
||||||
|
#[implementations(
|
||||||
|
List<String>,
|
||||||
|
List<bool>,
|
||||||
|
List<f32>,
|
||||||
|
List<f64>,
|
||||||
|
List<u32>,
|
||||||
|
List<u64>,
|
||||||
|
List<DVec2>,
|
||||||
|
List<DAffine2>,
|
||||||
|
List<Vector>,
|
||||||
|
List<Graphic>,
|
||||||
|
List<Raster<CPU>>,
|
||||||
|
List<Raster<GPU>>,
|
||||||
|
List<Color>,
|
||||||
|
List<Gradient>,
|
||||||
|
List<Artboard>,
|
||||||
|
)]
|
||||||
|
list: List<T>,
|
||||||
|
/// How many positions to shift each item. Positive values shift items toward the start of the list, negative toward the end.
|
||||||
|
amount: Item<SignedInteger>,
|
||||||
|
/// Whether items shifted off one end wrap around to the other. When off, they are dropped and the list gets shorter.
|
||||||
|
#[default(true)]
|
||||||
|
wrap: Item<bool>,
|
||||||
|
) -> List<T> {
|
||||||
|
let amount = amount.into_element() as i64;
|
||||||
|
let wrap = wrap.into_element();
|
||||||
|
let len = list.len() as i64;
|
||||||
|
if len == 0 {
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut items: Vec<Item<T>> = list.into_iter().collect();
|
||||||
|
if wrap {
|
||||||
|
items.rotate_left((((amount % len) + len) % len) as usize);
|
||||||
|
items.into_iter().collect()
|
||||||
|
} else if amount >= 0 {
|
||||||
|
items.into_iter().skip(amount.min(len) as usize).collect()
|
||||||
|
} else {
|
||||||
|
items.into_iter().take((len + amount).max(0) as usize).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Randomly reorders the items in a list. The same seed always produces the same ordering.
|
||||||
|
#[node_macro::node(category("General"))]
|
||||||
|
fn shuffle<T: Send + Sync + 'static>(
|
||||||
|
_: impl Ctx,
|
||||||
|
/// The list to have its items randomly reordered.
|
||||||
|
#[implementations(
|
||||||
|
List<String>,
|
||||||
|
List<bool>,
|
||||||
|
List<f32>,
|
||||||
|
List<f64>,
|
||||||
|
List<u32>,
|
||||||
|
List<u64>,
|
||||||
|
List<DVec2>,
|
||||||
|
List<DAffine2>,
|
||||||
|
List<Vector>,
|
||||||
|
List<Graphic>,
|
||||||
|
List<Raster<CPU>>,
|
||||||
|
List<Raster<GPU>>,
|
||||||
|
List<Color>,
|
||||||
|
List<Gradient>,
|
||||||
|
List<Artboard>,
|
||||||
|
)]
|
||||||
|
list: List<T>,
|
||||||
|
/// Seed to determine the unique variation of the random shuffle ordering. The same seed always produces the same ordering.
|
||||||
|
seed: Item<SeedValue>,
|
||||||
|
) -> List<T> {
|
||||||
|
let seed = seed.into_element();
|
||||||
|
let mut items: Vec<Item<T>> = list.into_iter().collect();
|
||||||
|
|
||||||
|
let mut rng = rand::rngs::StdRng::seed_from_u64(seed.into());
|
||||||
|
items.shuffle(&mut rng);
|
||||||
|
|
||||||
|
items.into_iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates a list of evenly spaced numbers, starting at a value and progressing by a step (which may be positive, negative, or zero) for a given count.
|
||||||
|
#[node_macro::node(category("General"), name("Number Sequence"))]
|
||||||
|
fn number_sequence(
|
||||||
|
_: impl Ctx,
|
||||||
|
_primary: (),
|
||||||
|
/// The first number in the sequence.
|
||||||
|
start: Item<f64>,
|
||||||
|
/// The amount added to reach each successive number.
|
||||||
|
#[default(1.)]
|
||||||
|
step: Item<f64>,
|
||||||
|
/// How many numbers to generate.
|
||||||
|
#[default(10)]
|
||||||
|
count: Item<u32>,
|
||||||
|
) -> List<f64> {
|
||||||
|
let (start, step, count) = (*start.element(), *step.element(), count.into_element());
|
||||||
|
|
||||||
|
(0..count).map(|i| Item::new_from_element(start + step * i as f64)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counts out the index of each item in a list (0, 1, 2, and so on), producing a list of numbers with one for each item.
|
||||||
|
#[node_macro::node(category("General"))]
|
||||||
|
fn list_indices(
|
||||||
|
_: impl Ctx,
|
||||||
|
/// The list whose items are counted.
|
||||||
|
list: ListDyn,
|
||||||
|
/// The number that the count begins from for the first item.
|
||||||
|
start_index: Item<SignedInteger>,
|
||||||
|
) -> List<f64> {
|
||||||
|
let start_index = start_index.into_element();
|
||||||
|
|
||||||
|
(0..list.len()).map(|index| Item::new_from_element(start_index + index as f64)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts a portion of a list, starting at "Start" and ending before "End".
|
||||||
|
///
|
||||||
|
/// Negative indices count from the end of the list. If the index of "Start" equals or exceeds "End", the result is an empty list.
|
||||||
|
#[node_macro::node(category("General"))]
|
||||||
|
fn list_slice<T: Send + Sync + 'static>(
|
||||||
|
_: impl Ctx,
|
||||||
|
/// The list of data to take a portion of.
|
||||||
|
#[implementations(
|
||||||
|
List<String>,
|
||||||
|
List<bool>,
|
||||||
|
List<f32>,
|
||||||
|
List<f64>,
|
||||||
|
List<u32>,
|
||||||
|
List<u64>,
|
||||||
|
List<DVec2>,
|
||||||
|
List<DAffine2>,
|
||||||
|
List<Vector>,
|
||||||
|
List<Graphic>,
|
||||||
|
List<Raster<CPU>>,
|
||||||
|
List<Raster<GPU>>,
|
||||||
|
List<Color>,
|
||||||
|
List<Gradient>,
|
||||||
|
List<Artboard>,
|
||||||
|
)]
|
||||||
|
list: List<T>,
|
||||||
|
/// The index of the first item in the portion. Negative indices count from the end of the list.
|
||||||
|
start: Item<SignedInteger>,
|
||||||
|
/// The index the portion ends before, which is not included. Zero or negative indices count from the end of the list.
|
||||||
|
end: Item<SignedInteger>,
|
||||||
|
) -> List<T> {
|
||||||
|
let (start, end) = (start.into_element(), end.into_element());
|
||||||
|
let total_items = list.len();
|
||||||
|
|
||||||
|
let start = if start < 0. {
|
||||||
|
total_items.saturating_sub(start.abs() as usize)
|
||||||
|
} else {
|
||||||
|
(start as usize).min(total_items)
|
||||||
|
};
|
||||||
|
let end = if end <= 0. {
|
||||||
|
total_items.saturating_sub(end.abs() as usize)
|
||||||
|
} else {
|
||||||
|
(end as usize).min(total_items)
|
||||||
|
};
|
||||||
|
|
||||||
|
if start >= end {
|
||||||
|
return List::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
list.into_iter().skip(start).take(end - start).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pairwise ordering used by the Sort node for element values. Types without a natural
|
||||||
|
/// order compare as equal, so the stable sort leaves their items in their original relative positions.
|
||||||
|
pub trait ElementOrder {
|
||||||
|
fn element_order(&self, _other: &Self) -> Ordering {
|
||||||
|
Ordering::Equal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl ElementOrder for String {
|
||||||
|
fn element_order(&self, other: &Self) -> Ordering {
|
||||||
|
self.cmp(other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl ElementOrder for bool {
|
||||||
|
fn element_order(&self, other: &Self) -> Ordering {
|
||||||
|
self.cmp(other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl ElementOrder for f32 {
|
||||||
|
fn element_order(&self, other: &Self) -> Ordering {
|
||||||
|
self.total_cmp(other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl ElementOrder for f64 {
|
||||||
|
fn element_order(&self, other: &Self) -> Ordering {
|
||||||
|
self.total_cmp(other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl ElementOrder for u32 {
|
||||||
|
fn element_order(&self, other: &Self) -> Ordering {
|
||||||
|
self.cmp(other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl ElementOrder for u64 {
|
||||||
|
fn element_order(&self, other: &Self) -> Ordering {
|
||||||
|
self.cmp(other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl ElementOrder for DVec2 {}
|
||||||
|
impl ElementOrder for DAffine2 {}
|
||||||
|
impl ElementOrder for Vector {}
|
||||||
|
impl ElementOrder for Graphic {}
|
||||||
|
impl ElementOrder for Raster<CPU> {}
|
||||||
|
impl ElementOrder for Raster<GPU> {}
|
||||||
|
impl ElementOrder for Color {}
|
||||||
|
impl ElementOrder for Gradient {}
|
||||||
|
impl ElementOrder for Artboard {}
|
||||||
|
|
||||||
|
/// Reorders a list's items from smallest to largest, either by each item's own value or by a parallel list of sortable values in the *Sort Order* input. The sort is stable, so items with the same sort order retain their relative positions.
|
||||||
|
#[node_macro::node(category("General"))]
|
||||||
|
fn sort<T: ElementOrder + Clone + Send + Sync + 'static, U: ElementOrder + Send + Sync + 'static>(
|
||||||
|
_: impl Ctx,
|
||||||
|
/// The list of data to reorder.
|
||||||
|
#[implementations(
|
||||||
|
List<String>, List<bool>, List<f32>, List<f64>, List<u32>, List<u64>, List<DVec2>, List<DAffine2>, List<Vector>, List<Graphic>, List<Raster<CPU>>, List<Raster<GPU>>, List<Color>, List<Gradient>, List<Artboard>,
|
||||||
|
List<String>, List<bool>, List<f32>, List<f64>, List<u32>, List<u64>, List<DVec2>, List<DAffine2>, List<Vector>, List<Graphic>, List<Raster<CPU>>, List<Raster<GPU>>, List<Color>, List<Gradient>, List<Artboard>,
|
||||||
|
List<String>, List<bool>, List<f32>, List<f64>, List<u32>, List<u64>, List<DVec2>, List<DAffine2>, List<Vector>, List<Graphic>, List<Raster<CPU>>, List<Raster<GPU>>, List<Color>, List<Gradient>, List<Artboard>,
|
||||||
|
)]
|
||||||
|
list: List<T>,
|
||||||
|
/// The optional list of orderable values, corresponding item-to-item with the input list, to sort by instead of the items' own values.
|
||||||
|
#[expose]
|
||||||
|
#[implementations(
|
||||||
|
List<f64>, List<f64>, List<f64>, List<f64>, List<f64>, List<f64>, List<f64>, List<f64>, List<f64>, List<f64>, List<f64>, List<f64>, List<f64>, List<f64>, List<f64>,
|
||||||
|
List<String>, List<String>, List<String>, List<String>, List<String>, List<String>, List<String>, List<String>, List<String>, List<String>, List<String>, List<String>, List<String>, List<String>, List<String>,
|
||||||
|
List<bool>, List<bool>, List<bool>, List<bool>, List<bool>, List<bool>, List<bool>, List<bool>, List<bool>, List<bool>, List<bool>, List<bool>, List<bool>, List<bool>, List<bool>,
|
||||||
|
)]
|
||||||
|
sort_order: List<U>,
|
||||||
|
/// Reverses the sorted list order, following descending order instead of ascending (numbers largest-to-smallest, strings Z-to-A, etc.).
|
||||||
|
reverse: Item<bool>,
|
||||||
|
) -> List<T> {
|
||||||
|
let reverse = reverse.into_element();
|
||||||
|
|
||||||
|
// Order by the parallel keys when provided (repeating the last if there are fewer keys than items), otherwise by the element values themselves
|
||||||
|
let keys = sort_order.iter_element_values().as_slice();
|
||||||
|
let elements: Vec<&T> = list.iter_element_values().collect();
|
||||||
|
|
||||||
|
let mut order: Vec<usize> = (0..list.len()).collect();
|
||||||
|
order.sort_by(|&a, &b| {
|
||||||
|
let ordering = match keys {
|
||||||
|
[] => elements[a].element_order(elements[b]),
|
||||||
|
keys => keys[a.min(keys.len() - 1)].element_order(&keys[b.min(keys.len() - 1)]),
|
||||||
|
};
|
||||||
|
if reverse { ordering.reverse() } else { ordering }
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut result = List::new();
|
||||||
|
for index in order {
|
||||||
|
if let Some(item) = list.clone_item(index) {
|
||||||
|
result.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
#[node_macro::node(category("General"))]
|
#[node_macro::node(category("General"))]
|
||||||
async fn map<Item: AnyHash + Send + Sync + CacheHash>(
|
async fn map<Item: AnyHash + Send + Sync + CacheHash>(
|
||||||
ctx: impl Ctx + CloneVarArgs + ExtractAll,
|
ctx: impl Ctx + CloneVarArgs + ExtractAll,
|
||||||
@@ -215,8 +540,8 @@ pub fn path_of_subgraph(_: impl Ctx, node_path: Item<NodeIdPath>) -> Item<NodeId
|
|||||||
Item::new_from_element(NodeIdPath(node_path.into_iter().take(len.saturating_sub(1)).collect()))
|
Item::new_from_element(NodeIdPath(node_path.into_iter().take(len.saturating_sub(1)).collect()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets a named attribute on the input `List`, computing one value per item via the value-producing input. That input
|
/// Sets a named attribute on the input list, computing one value per item via the value-producing input. That input
|
||||||
/// is evaluated once per item, with the item's index and the item itself (as a `List` containing only that item,
|
/// is evaluated once per item, with the item's index and the item itself (as a list containing only that item,
|
||||||
/// passed as a vararg) provided via context, so the upstream pipeline can return a different value per item that may
|
/// passed as a vararg) provided via context, so the upstream pipeline can return a different value per item that may
|
||||||
/// be derived from the item's own data. If the attribute already exists, its values are replaced; if not, it's added.
|
/// be derived from the item's own data. If the attribute already exists, its values are replaced; if not, it's added.
|
||||||
/// The value is type-erased into an `Item<AttributeValueDyn>` by the auto-inserted input adapter, so this node only
|
/// The value is type-erased into an `Item<AttributeValueDyn>` by the auto-inserted input adapter, so this node only
|
||||||
@@ -224,7 +549,7 @@ pub fn path_of_subgraph(_: impl Ctx, node_path: Item<NodeIdPath>) -> Item<NodeId
|
|||||||
#[node_macro::node(category("Attributes: Write"))]
|
#[node_macro::node(category("Attributes: Write"))]
|
||||||
async fn write_attribute<T: AnyHash + Clone + Send + Sync + CacheHash>(
|
async fn write_attribute<T: AnyHash + Clone + Send + Sync + CacheHash>(
|
||||||
ctx: impl ExtractAll + CloneVarArgs + Ctx,
|
ctx: impl ExtractAll + CloneVarArgs + Ctx,
|
||||||
/// The `List` to set the named attribute on (one value per item).
|
/// The list to set the named attribute on (one value per item).
|
||||||
#[implementations(
|
#[implementations(
|
||||||
List<String>,
|
List<String>,
|
||||||
List<bool>,
|
List<bool>,
|
||||||
@@ -473,11 +798,11 @@ fn read_attribute_raster(
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Joins two `List`s of the same type, extending the base `List` with the items from the new `List`.
|
/// Joins two lists of the same type, extending the base list with the items from the new list.
|
||||||
#[node_macro::node(category("General"))]
|
#[node_macro::node(category("General"))]
|
||||||
pub async fn extend<T: 'n + Send + Clone>(
|
pub async fn extend<T: 'n + Send + Clone>(
|
||||||
_: impl Ctx,
|
_: impl Ctx,
|
||||||
/// The `List` whose items will appear at the start of the extended `List`.
|
/// The list whose items will appear at the start of the extended list.
|
||||||
#[implementations(
|
#[implementations(
|
||||||
List<String>,
|
List<String>,
|
||||||
List<bool>,
|
List<bool>,
|
||||||
@@ -496,7 +821,7 @@ pub async fn extend<T: 'n + Send + Clone>(
|
|||||||
List<Artboard>,
|
List<Artboard>,
|
||||||
)]
|
)]
|
||||||
base: List<T>,
|
base: List<T>,
|
||||||
/// The `List` whose items will appear at the end of the extended `List`.
|
/// The list whose items will appear at the end of the extended list.
|
||||||
#[expose]
|
#[expose]
|
||||||
#[implementations(
|
#[implementations(
|
||||||
List<String>,
|
List<String>,
|
||||||
@@ -724,3 +1049,128 @@ fn colors_to_gradient<T: IntoGraphicList>(_: impl Ctx, #[implementations(List<Gr
|
|||||||
});
|
});
|
||||||
Item::new_from_element(Gradient::new(colors))
|
Item::new_from_element(Gradient::new(colors))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn list_of<T>(elements: impl IntoIterator<Item = T>) -> List<T> {
|
||||||
|
elements.into_iter().map(Item::new_from_element).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn elements<T: Clone>(list: &List<T>) -> Vec<T> {
|
||||||
|
list.iter_element_values().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sorts_elements_by_their_natural_order() {
|
||||||
|
let list = list_of(["banana".to_string(), "apple".to_string(), "cherry".to_string()]);
|
||||||
|
let sorted = sort((), list, List::<f64>::new(), Item::new_from_element(false));
|
||||||
|
assert_eq!(elements(&sorted), ["apple", "banana", "cherry"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sorts_elements_in_reverse() {
|
||||||
|
let list = list_of([3., 1., 2.]);
|
||||||
|
let sorted = sort((), list, List::<f64>::new(), Item::new_from_element(true));
|
||||||
|
assert_eq!(elements(&sorted), [3., 2., 1.]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sort_order_keys_override_element_order() {
|
||||||
|
let list = list_of(["apple".to_string(), "banana".to_string(), "cherry".to_string()]);
|
||||||
|
let sorted = sort((), list, list_of([2., 0., 1.]), Item::new_from_element(false));
|
||||||
|
assert_eq!(elements(&sorted), ["banana", "cherry", "apple"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn short_sort_order_repeats_its_last_key() {
|
||||||
|
let list = list_of(["a".to_string(), "b".to_string(), "c".to_string()]);
|
||||||
|
let sorted = sort((), list, list_of([2., 1.]), Item::new_from_element(false));
|
||||||
|
assert_eq!(elements(&sorted), ["b", "c", "a"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn long_sort_order_ignores_its_extra_keys() {
|
||||||
|
let list = list_of([1., 2.]);
|
||||||
|
let sorted = sort((), list, list_of([3., 1., 0., 5.]), Item::new_from_element(false));
|
||||||
|
assert_eq!(elements(&sorted), [2., 1.]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_sort_order_keys_order_items_alphabetically() {
|
||||||
|
let list = list_of([1., 2., 3.]);
|
||||||
|
let sorted = sort((), list, list_of(["c".to_string(), "a".to_string(), "b".to_string()]), Item::new_from_element(false));
|
||||||
|
assert_eq!(elements(&sorted), [2., 3., 1.]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsortable_elements_keep_their_original_order() {
|
||||||
|
let list = list_of([DVec2::new(3., 3.), DVec2::new(1., 1.), DVec2::new(2., 2.)]);
|
||||||
|
let sorted = sort((), list, List::<f64>::new(), Item::new_from_element(false));
|
||||||
|
assert_eq!(elements(&sorted), [DVec2::new(3., 3.), DVec2::new(1., 1.), DVec2::new(2., 2.)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shift_wraps_items_around() {
|
||||||
|
let forward = shift((), list_of([1., 2., 3., 4.]), Item::new_from_element(1.), Item::new_from_element(true));
|
||||||
|
assert_eq!(elements(&forward), [2., 3., 4., 1.]);
|
||||||
|
|
||||||
|
let backward = shift((), list_of([1., 2., 3., 4.]), Item::new_from_element(-1.), Item::new_from_element(true));
|
||||||
|
assert_eq!(elements(&backward), [4., 1., 2., 3.]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shift_without_wrapping_drops_items() {
|
||||||
|
let dropped_front = shift((), list_of([1., 2., 3., 4.]), Item::new_from_element(1.), Item::new_from_element(false));
|
||||||
|
assert_eq!(elements(&dropped_front), [2., 3., 4.]);
|
||||||
|
|
||||||
|
let dropped_back = shift((), list_of([1., 2., 3., 4.]), Item::new_from_element(-1.), Item::new_from_element(false));
|
||||||
|
assert_eq!(elements(&dropped_back), [1., 2., 3.]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shuffle_is_deterministic_and_preserves_elements() {
|
||||||
|
let original = [1., 2., 3., 4., 5., 6., 7., 8.];
|
||||||
|
let first = shuffle((), list_of(original), Item::new_from_element(42_u32));
|
||||||
|
let second = shuffle((), list_of(original), Item::new_from_element(42_u32));
|
||||||
|
assert_eq!(elements(&first), elements(&second), "the same seed should always produce the same ordering");
|
||||||
|
|
||||||
|
let mut recovered = elements(&first);
|
||||||
|
recovered.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||||
|
assert_eq!(recovered, original, "shuffling should preserve all the elements");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn number_sequence_generates_evenly_spaced_numbers() {
|
||||||
|
let sequence = number_sequence((), (), Item::new_from_element(0.), Item::new_from_element(2.), Item::new_from_element(4_u32));
|
||||||
|
assert_eq!(elements(&sequence), [0., 2., 4., 6.]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_indices_counts_each_item() {
|
||||||
|
let indices = list_indices((), ListDyn::from(list_of(["a".to_string(), "b".to_string(), "c".to_string()])), Item::new_from_element(0.));
|
||||||
|
assert_eq!(elements(&indices), [0., 1., 2.]);
|
||||||
|
|
||||||
|
let from_one = list_indices((), ListDyn::from(list_of(["a".to_string(), "b".to_string(), "c".to_string()])), Item::new_from_element(1.));
|
||||||
|
assert_eq!(elements(&from_one), [1., 2., 3.]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_slice_takes_the_portion_between_start_and_end() {
|
||||||
|
let portion = list_slice((), list_of([1., 2., 3., 4., 5.]), Item::new_from_element(1.), Item::new_from_element(3.));
|
||||||
|
assert_eq!(elements(&portion), [2., 3.]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_slice_resolves_negative_indices_from_the_end() {
|
||||||
|
let portion = list_slice((), list_of([1., 2., 3., 4., 5.]), Item::new_from_element(-2.), Item::new_from_element(0.));
|
||||||
|
assert_eq!(elements(&portion), [4., 5.], "an end of zero reaches through the end of the list");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_slice_yields_nothing_when_start_reaches_end() {
|
||||||
|
let portion = list_slice((), list_of([1., 2., 3., 4., 5.]), Item::new_from_element(3.), Item::new_from_element(3.));
|
||||||
|
assert!(elements(&portion).is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user