mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2025-08-04 05:18:19 +00:00
Add a stack-based Boolean Operation layer node (#1813)
* Multiple boolean operation node * Change boolean operation ordering * Complete layer boolean operation node * Automatically insert new boolean operation node * Remove divide operation * Fix subtract operations * Remove stack data from boolean operation properties * Fix images and custom vectors * Code cleanup * Use slice instead of iter to avoid infinite type recursion --------- Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
parent
f4e3e5ab2a
commit
9d749c49fb
14 changed files with 319 additions and 153 deletions
|
@ -57,7 +57,7 @@ impl core::hash::Hash for GraphicGroup {
|
|||
}
|
||||
|
||||
/// The possible forms of graphical content held in a Vec by the `elements` field of [`GraphicElement`].
|
||||
/// Can be another recursively nested [`GraphicGroup`], [`VectorData`], an [`ImageFrame`], text (not yet implemented), or an [`Artboard`].
|
||||
/// Can be another recursively nested [`GraphicGroup`], a [`VectorData`] shape, an [`ImageFrame`], or an [`Artboard`].
|
||||
#[derive(Clone, Debug, Hash, PartialEq, DynAny)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum GraphicElement {
|
||||
|
@ -67,10 +67,6 @@ pub enum GraphicElement {
|
|||
VectorData(Box<VectorData>),
|
||||
/// A bitmap image with a finite position and extent, equivalent to the SVG <image> tag: https://developer.mozilla.org/en-US/docs/Web/SVG/Element/image
|
||||
ImageFrame(ImageFrame<Color>),
|
||||
// TODO: Switch from `String` to a proper formatted typography type
|
||||
/// Text, equivalent to the SVG <text> tag: https://developer.mozilla.org/en-US/docs/Web/SVG/Element/text
|
||||
/// (Not yet implemented.)
|
||||
Text(String),
|
||||
/// The bounds for displaying a page of contained content
|
||||
Artboard(Artboard),
|
||||
}
|
||||
|
@ -366,28 +362,6 @@ impl GraphicElement {
|
|||
bounding_box: None,
|
||||
}))
|
||||
}
|
||||
GraphicElement::Text(text) => usvg::Node::Text(Box::new(usvg::Text {
|
||||
id: String::new(),
|
||||
abs_transform: usvg::Transform::identity(),
|
||||
rendering_mode: usvg::TextRendering::OptimizeSpeed,
|
||||
writing_mode: usvg::WritingMode::LeftToRight,
|
||||
chunks: vec![usvg::TextChunk {
|
||||
text: text.clone(),
|
||||
x: None,
|
||||
y: None,
|
||||
anchor: usvg::TextAnchor::Start,
|
||||
spans: vec![],
|
||||
text_flow: usvg::TextFlow::Linear,
|
||||
}],
|
||||
dx: Vec::new(),
|
||||
dy: Vec::new(),
|
||||
rotate: Vec::new(),
|
||||
bounding_box: None,
|
||||
abs_bounding_box: None,
|
||||
stroke_bounding_box: None,
|
||||
abs_stroke_bounding_box: None,
|
||||
flattened: None,
|
||||
})),
|
||||
GraphicElement::GraphicGroup(group) => {
|
||||
let mut group_element = usvg::Group::default();
|
||||
|
||||
|
|
|
@ -554,7 +554,6 @@ impl GraphicElementRendered for GraphicElement {
|
|||
match self {
|
||||
GraphicElement::VectorData(vector_data) => vector_data.render_svg(render, render_params),
|
||||
GraphicElement::ImageFrame(image_frame) => image_frame.render_svg(render, render_params),
|
||||
GraphicElement::Text(_) => todo!("Render a text GraphicElement"),
|
||||
GraphicElement::GraphicGroup(graphic_group) => graphic_group.render_svg(render, render_params),
|
||||
GraphicElement::Artboard(artboard) => artboard.render_svg(render, render_params),
|
||||
}
|
||||
|
@ -564,7 +563,6 @@ impl GraphicElementRendered for GraphicElement {
|
|||
match self {
|
||||
GraphicElement::VectorData(vector_data) => GraphicElementRendered::bounding_box(&**vector_data, transform),
|
||||
GraphicElement::ImageFrame(image_frame) => image_frame.bounding_box(transform),
|
||||
GraphicElement::Text(_) => todo!("Bounds of a text GraphicElement"),
|
||||
GraphicElement::GraphicGroup(graphic_group) => graphic_group.bounding_box(transform),
|
||||
GraphicElement::Artboard(artboard) => artboard.bounding_box(transform),
|
||||
}
|
||||
|
@ -574,7 +572,6 @@ impl GraphicElementRendered for GraphicElement {
|
|||
match self {
|
||||
GraphicElement::VectorData(vector_data) => vector_data.add_click_targets(click_targets),
|
||||
GraphicElement::ImageFrame(image_frame) => image_frame.add_click_targets(click_targets),
|
||||
GraphicElement::Text(_) => todo!("click target for text GraphicElement"),
|
||||
GraphicElement::GraphicGroup(graphic_group) => graphic_group.add_click_targets(click_targets),
|
||||
GraphicElement::Artboard(artboard) => artboard.add_click_targets(click_targets),
|
||||
}
|
||||
|
@ -584,7 +581,6 @@ impl GraphicElementRendered for GraphicElement {
|
|||
match self {
|
||||
GraphicElement::VectorData(vector_data) => vector_data.to_usvg_node(),
|
||||
GraphicElement::ImageFrame(image_frame) => image_frame.to_usvg_node(),
|
||||
GraphicElement::Text(text) => text.to_usvg_node(),
|
||||
GraphicElement::GraphicGroup(graphic_group) => graphic_group.to_usvg_node(),
|
||||
GraphicElement::Artboard(artboard) => artboard.to_usvg_node(),
|
||||
}
|
||||
|
@ -594,7 +590,6 @@ impl GraphicElementRendered for GraphicElement {
|
|||
match self {
|
||||
GraphicElement::VectorData(vector_data) => vector_data.contains_artboard(),
|
||||
GraphicElement::ImageFrame(image_frame) => image_frame.contains_artboard(),
|
||||
GraphicElement::Text(text) => text.contains_artboard(),
|
||||
GraphicElement::GraphicGroup(graphic_group) => graphic_group.contains_artboard(),
|
||||
GraphicElement::Artboard(artboard) => artboard.contains_artboard(),
|
||||
}
|
||||
|
|
|
@ -75,7 +75,6 @@ impl Transform for GraphicElement {
|
|||
match self {
|
||||
GraphicElement::VectorData(vector_shape) => vector_shape.transform(),
|
||||
GraphicElement::ImageFrame(image_frame) => image_frame.transform(),
|
||||
GraphicElement::Text(_) => todo!("Transform of text"),
|
||||
GraphicElement::GraphicGroup(graphic_group) => graphic_group.transform(),
|
||||
GraphicElement::Artboard(artboard) => artboard.transform(),
|
||||
}
|
||||
|
@ -84,7 +83,6 @@ impl Transform for GraphicElement {
|
|||
match self {
|
||||
GraphicElement::VectorData(vector_shape) => vector_shape.local_pivot(pivot),
|
||||
GraphicElement::ImageFrame(image_frame) => image_frame.local_pivot(pivot),
|
||||
GraphicElement::Text(_) => todo!("Transform of text"),
|
||||
GraphicElement::GraphicGroup(graphic_group) => graphic_group.local_pivot(pivot),
|
||||
GraphicElement::Artboard(artboard) => artboard.local_pivot(pivot),
|
||||
}
|
||||
|
@ -93,7 +91,6 @@ impl Transform for GraphicElement {
|
|||
match self {
|
||||
GraphicElement::VectorData(vector_shape) => vector_shape.decompose_scale(),
|
||||
GraphicElement::ImageFrame(image_frame) => image_frame.decompose_scale(),
|
||||
GraphicElement::Text(_) => todo!("Transform of text"),
|
||||
GraphicElement::GraphicGroup(graphic_group) => graphic_group.decompose_scale(),
|
||||
GraphicElement::Artboard(artboard) => artboard.decompose_scale(),
|
||||
}
|
||||
|
@ -104,7 +101,6 @@ impl TransformMut for GraphicElement {
|
|||
match self {
|
||||
GraphicElement::VectorData(vector_shape) => vector_shape.transform_mut(),
|
||||
GraphicElement::ImageFrame(image_frame) => image_frame.transform_mut(),
|
||||
GraphicElement::Text(_) => todo!("Transform of text"),
|
||||
GraphicElement::GraphicGroup(graphic_group) => graphic_group.transform_mut(),
|
||||
GraphicElement::Artboard(_) => todo!("Transform of artboard"),
|
||||
}
|
||||
|
|
|
@ -18,23 +18,21 @@ pub enum BooleanOperation {
|
|||
SubtractBack,
|
||||
Intersect,
|
||||
Difference,
|
||||
Divide,
|
||||
}
|
||||
|
||||
impl BooleanOperation {
|
||||
pub fn list() -> [BooleanOperation; 6] {
|
||||
pub fn list() -> [BooleanOperation; 5] {
|
||||
[
|
||||
BooleanOperation::Union,
|
||||
BooleanOperation::SubtractFront,
|
||||
BooleanOperation::SubtractBack,
|
||||
BooleanOperation::Intersect,
|
||||
BooleanOperation::Difference,
|
||||
BooleanOperation::Divide,
|
||||
]
|
||||
}
|
||||
|
||||
pub fn icons() -> [&'static str; 6] {
|
||||
["BooleanUnion", "BooleanSubtractFront", "BooleanSubtractBack", "BooleanIntersect", "BooleanDifference", "BooleanDivide"]
|
||||
pub fn icons() -> [&'static str; 5] {
|
||||
["BooleanUnion", "BooleanSubtractFront", "BooleanSubtractBack", "BooleanIntersect", "BooleanDifference"]
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -46,7 +44,6 @@ impl core::fmt::Display for BooleanOperation {
|
|||
BooleanOperation::SubtractBack => write!(f, "Subtract Back"),
|
||||
BooleanOperation::Intersect => write!(f, "Intersect"),
|
||||
BooleanOperation::Difference => write!(f, "Difference"),
|
||||
BooleanOperation::Divide => write!(f, "Divide"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,21 +1,23 @@
|
|||
use crate::Node;
|
||||
|
||||
use bezier_rs::{ManipulatorGroup, Subpath};
|
||||
use graphene_core::transform::Footprint;
|
||||
use graphene_core::vector::misc::BooleanOperation;
|
||||
use graphene_core::raster::ImageFrame;
|
||||
pub use graphene_core::vector::*;
|
||||
use graphene_core::Color;
|
||||
use graphene_core::{transform::Footprint, GraphicGroup};
|
||||
use graphene_core::{vector::misc::BooleanOperation, GraphicElement};
|
||||
|
||||
use futures::Future;
|
||||
use glam::{DAffine2, DVec2};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
pub struct BooleanOperationNode<LowerVectorData, BooleanOp> {
|
||||
pub struct BinaryBooleanOperationNode<LowerVectorData, BooleanOp> {
|
||||
lower_vector_data: LowerVectorData,
|
||||
boolean_operation: BooleanOp,
|
||||
}
|
||||
|
||||
#[node_macro::node_fn(BooleanOperationNode)]
|
||||
async fn boolean_operation_node<Fut: Future<Output = VectorData>>(
|
||||
#[node_macro::node_fn(BinaryBooleanOperationNode)]
|
||||
async fn binary_boolean_operation_node<Fut: Future<Output = VectorData>>(
|
||||
upper_vector_data: VectorData,
|
||||
lower_vector_data: impl Node<Footprint, Output = Fut>,
|
||||
boolean_operation: BooleanOperation,
|
||||
|
@ -39,7 +41,6 @@ async fn boolean_operation_node<Fut: Future<Output = VectorData>>(
|
|||
BooleanOperation::SubtractBack => boolean_subtract(upper_path_string, lower_path_string),
|
||||
BooleanOperation::Intersect => boolean_intersect(upper_path_string, lower_path_string),
|
||||
BooleanOperation::Difference => boolean_difference(upper_path_string, lower_path_string),
|
||||
BooleanOperation::Divide => boolean_divide(upper_path_string, lower_path_string),
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -51,9 +52,182 @@ async fn boolean_operation_node<Fut: Future<Output = VectorData>>(
|
|||
result
|
||||
}
|
||||
|
||||
pub struct BooleanOperationNode<BooleanOp> {
|
||||
boolean_operation: BooleanOp,
|
||||
}
|
||||
|
||||
#[node_macro::node_fn(BooleanOperationNode)]
|
||||
fn boolean_operation_node(graphic_group: GraphicGroup, boolean_operation: BooleanOperation) -> VectorData {
|
||||
fn vector_from_image<P: graphene_core::raster::Pixel>(image_frame: &ImageFrame<P>) -> VectorData {
|
||||
let corner1 = DVec2::ZERO;
|
||||
let corner2 = DVec2::new(1., 1.);
|
||||
let mut subpath = Subpath::new_rect(corner1, corner2);
|
||||
subpath.apply_transform(image_frame.transform);
|
||||
let mut vector_data = VectorData::from_subpath(subpath);
|
||||
vector_data
|
||||
.style
|
||||
.set_fill(graphene_core::vector::style::Fill::Solid(Color::from_rgb_str("777777").unwrap().to_gamma_srgb()));
|
||||
vector_data
|
||||
}
|
||||
|
||||
fn union_vector_data(graphic_element: &GraphicElement) -> VectorData {
|
||||
match graphic_element {
|
||||
GraphicElement::VectorData(vector_data) => *vector_data.clone(),
|
||||
// Union all vector data in the graphic group into a single vector
|
||||
GraphicElement::GraphicGroup(graphic_group) => {
|
||||
let vector_data = collect_vector_data(graphic_group);
|
||||
boolean_operation_on_vector_data(&vector_data, BooleanOperation::Union)
|
||||
}
|
||||
GraphicElement::ImageFrame(image) => vector_from_image(image),
|
||||
// Union all vector data in the artboard into a single vector
|
||||
GraphicElement::Artboard(artboard) => {
|
||||
let artboard_subpath = Subpath::new_rect(artboard.location.as_dvec2(), artboard.location.as_dvec2() + artboard.dimensions.as_dvec2());
|
||||
|
||||
let mut artboard_vector = VectorData::from_subpath(artboard_subpath);
|
||||
artboard_vector.style.set_fill(graphene_core::vector::style::Fill::Solid(artboard.background));
|
||||
|
||||
let mut vector_data = vec![artboard_vector];
|
||||
vector_data.extend(collect_vector_data(&artboard.graphic_group).into_iter());
|
||||
|
||||
boolean_operation_on_vector_data(&vector_data, BooleanOperation::Union)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_vector_data(graphic_group: &GraphicGroup) -> Vec<VectorData> {
|
||||
// Ensure all non vector data in the graphic group is converted to vector data
|
||||
graphic_group.iter().map(union_vector_data).collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
fn subtract<'a>(vector_data: impl Iterator<Item = &'a VectorData>) -> VectorData {
|
||||
let mut vector_data = vector_data.into_iter();
|
||||
let mut result = vector_data.next().cloned().unwrap_or_default();
|
||||
let mut next_vector_data = vector_data.next();
|
||||
|
||||
while let Some(lower_vector_data) = next_vector_data {
|
||||
let transform_of_lower_into_space_of_upper = result.transform.inverse() * lower_vector_data.transform;
|
||||
|
||||
let upper_path_string = to_svg_string(&result, DAffine2::IDENTITY);
|
||||
let lower_path_string = to_svg_string(&lower_vector_data, transform_of_lower_into_space_of_upper);
|
||||
|
||||
#[allow(unused_unsafe)]
|
||||
let boolean_operation_string = unsafe { boolean_subtract(upper_path_string, lower_path_string) };
|
||||
let boolean_operation_result = from_svg_string(&boolean_operation_string);
|
||||
|
||||
result.colinear_manipulators = boolean_operation_result.colinear_manipulators;
|
||||
result.point_domain = boolean_operation_result.point_domain;
|
||||
result.segment_domain = boolean_operation_result.segment_domain;
|
||||
result.region_domain = boolean_operation_result.region_domain;
|
||||
|
||||
next_vector_data = vector_data.next();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn boolean_operation_on_vector_data(vector_data: &[VectorData], boolean_operation: BooleanOperation) -> VectorData {
|
||||
match boolean_operation {
|
||||
BooleanOperation::Union => {
|
||||
// Reverse vector data so that the result style is the style of the first vector data
|
||||
let mut vector_data = vector_data.iter().rev();
|
||||
let mut result = vector_data.next().cloned().unwrap_or_default();
|
||||
let mut second_vector_data = Some(vector_data.next().unwrap_or(const { &VectorData::empty() }));
|
||||
|
||||
// Loop over all vector data and union it with the result
|
||||
while let Some(lower_vector_data) = second_vector_data {
|
||||
let transform_of_lower_into_space_of_upper = result.transform.inverse() * lower_vector_data.transform;
|
||||
|
||||
let upper_path_string = to_svg_string(&result, DAffine2::IDENTITY);
|
||||
let lower_path_string = to_svg_string(lower_vector_data, transform_of_lower_into_space_of_upper);
|
||||
|
||||
#[allow(unused_unsafe)]
|
||||
let boolean_operation_string = unsafe { boolean_union(upper_path_string, lower_path_string) };
|
||||
let boolean_operation_result = from_svg_string(&boolean_operation_string);
|
||||
|
||||
result.colinear_manipulators = boolean_operation_result.colinear_manipulators;
|
||||
result.point_domain = boolean_operation_result.point_domain;
|
||||
result.segment_domain = boolean_operation_result.segment_domain;
|
||||
result.region_domain = boolean_operation_result.region_domain;
|
||||
second_vector_data = vector_data.next();
|
||||
}
|
||||
result
|
||||
}
|
||||
BooleanOperation::SubtractFront => subtract(vector_data.iter()),
|
||||
BooleanOperation::SubtractBack => subtract(vector_data.iter().rev()),
|
||||
BooleanOperation::Intersect => {
|
||||
let mut vector_data = vector_data.iter().rev();
|
||||
let mut result = vector_data.next().cloned().unwrap_or_default();
|
||||
let mut second_vector_data = Some(vector_data.next().unwrap_or(const { &VectorData::empty() }));
|
||||
|
||||
// For each vector data, set the result to the intersection of that data and the result
|
||||
while let Some(lower_vector_data) = second_vector_data {
|
||||
let transform_of_lower_into_space_of_upper = result.transform.inverse() * lower_vector_data.transform;
|
||||
|
||||
let upper_path_string = to_svg_string(&result, DAffine2::IDENTITY);
|
||||
let lower_path_string = to_svg_string(lower_vector_data, transform_of_lower_into_space_of_upper);
|
||||
|
||||
#[allow(unused_unsafe)]
|
||||
let boolean_operation_string = unsafe { boolean_intersect(upper_path_string, lower_path_string) };
|
||||
let boolean_operation_result = from_svg_string(&boolean_operation_string);
|
||||
|
||||
result.colinear_manipulators = boolean_operation_result.colinear_manipulators;
|
||||
result.point_domain = boolean_operation_result.point_domain;
|
||||
result.segment_domain = boolean_operation_result.segment_domain;
|
||||
result.region_domain = boolean_operation_result.region_domain;
|
||||
second_vector_data = vector_data.next();
|
||||
}
|
||||
result
|
||||
}
|
||||
BooleanOperation::Difference => {
|
||||
let mut vector_data_iter = vector_data.iter().rev();
|
||||
let mut any_intersection = VectorData::empty();
|
||||
let mut second_vector_data = Some(vector_data_iter.next().unwrap_or(const { &VectorData::empty() }));
|
||||
|
||||
// Find where all vector data intersect at least once
|
||||
while let Some(lower_vector_data) = second_vector_data {
|
||||
let all_other_vector_data = boolean_operation_on_vector_data(&vector_data.iter().filter(|v| v != &lower_vector_data).cloned().collect::<Vec<_>>(), BooleanOperation::Union);
|
||||
|
||||
let transform_of_lower_into_space_of_upper = all_other_vector_data.transform.inverse() * lower_vector_data.transform;
|
||||
|
||||
let upper_path_string = to_svg_string(&all_other_vector_data, DAffine2::IDENTITY);
|
||||
let lower_path_string = to_svg_string(lower_vector_data, transform_of_lower_into_space_of_upper);
|
||||
|
||||
#[allow(unused_unsafe)]
|
||||
let boolean_intersection_string = unsafe { boolean_intersect(upper_path_string, lower_path_string) };
|
||||
let mut boolean_intersection_result = from_svg_string(&boolean_intersection_string);
|
||||
|
||||
boolean_intersection_result.transform = all_other_vector_data.transform;
|
||||
boolean_intersection_result.style = all_other_vector_data.style.clone();
|
||||
boolean_intersection_result.alpha_blending = all_other_vector_data.alpha_blending;
|
||||
|
||||
let transform_of_lower_into_space_of_upper = boolean_intersection_result.transform.inverse() * any_intersection.transform;
|
||||
|
||||
let upper_path_string = to_svg_string(&boolean_intersection_result, DAffine2::IDENTITY);
|
||||
let lower_path_string = to_svg_string(&any_intersection, transform_of_lower_into_space_of_upper);
|
||||
|
||||
#[allow(unused_unsafe)]
|
||||
let union_result = from_svg_string(&unsafe { boolean_union(upper_path_string, lower_path_string) });
|
||||
any_intersection = union_result;
|
||||
|
||||
any_intersection.transform = boolean_intersection_result.transform;
|
||||
any_intersection.style = boolean_intersection_result.style.clone();
|
||||
any_intersection.alpha_blending = boolean_intersection_result.alpha_blending;
|
||||
|
||||
second_vector_data = vector_data_iter.next();
|
||||
}
|
||||
// Subtract the area where they intersect at least once from the union of all vector data
|
||||
let union = boolean_operation_on_vector_data(vector_data, BooleanOperation::Union);
|
||||
boolean_operation_on_vector_data(&[union, any_intersection], BooleanOperation::SubtractFront)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The first index is the bottom of the stack
|
||||
boolean_operation_on_vector_data(&collect_vector_data(&graphic_group), boolean_operation)
|
||||
}
|
||||
|
||||
fn to_svg_string(vector: &VectorData, transform: DAffine2) -> String {
|
||||
let mut path = String::new();
|
||||
for (_, subpath) in vector.region_bezier_paths() {
|
||||
for subpath in vector.stroke_bezier_paths() {
|
||||
let _ = subpath.subpath_to_svg(&mut path, transform);
|
||||
}
|
||||
path
|
||||
|
@ -125,6 +299,4 @@ extern "C" {
|
|||
fn boolean_intersect(path1: String, path2: String) -> String;
|
||||
#[wasm_bindgen(js_name = booleanDifference)]
|
||||
fn boolean_difference(path1: String, path2: String) -> String;
|
||||
#[wasm_bindgen(js_name = booleanDivide)]
|
||||
fn boolean_divide(path1: String, path2: String) -> String;
|
||||
}
|
||||
|
|
|
@ -719,7 +719,8 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
|
|||
register_node!(graphene_core::vector::BoundingBoxNode, input: VectorData, params: []),
|
||||
register_node!(graphene_core::vector::SolidifyStrokeNode, input: VectorData, params: []),
|
||||
register_node!(graphene_core::vector::CircularRepeatNode<_, _, _>, input: VectorData, params: [f64, f64, u32]),
|
||||
async_node!(graphene_std::vector::BooleanOperationNode<_, _>, input: VectorData, output: VectorData, fn_params: [Footprint => VectorData, () => graphene_core::vector::misc::BooleanOperation]),
|
||||
async_node!(graphene_std::vector::BinaryBooleanOperationNode<_, _>, input: VectorData, output: VectorData, fn_params: [Footprint => VectorData, () => graphene_core::vector::misc::BooleanOperation]),
|
||||
register_node!(graphene_std::vector::BooleanOperationNode<_>, input: GraphicGroup, fn_params: [() => graphene_core::vector::misc::BooleanOperation]),
|
||||
vec![(
|
||||
ProtoNodeIdentifier::new("graphene_core::transform::CullNode<_>"),
|
||||
|args| {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue