mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-19 10:58:04 +08:00
Restructure node crates (#3384)
* Restructure node-graph folder * Fix wasm compilation * Move node definitions out of *-types crates * Cleanup * Fix warnings * Fix warnings * Start adding migrations * Add migrations and move memo nodes to gcore * Move nodes/gsvg-render -> rendering * Replace some hard coded identifiers and fix automatic conversion * Fix Vec2Value node migration * Fix formatting * Add more migrations * Cleanup features * Fix core_types::raster import * Update demo artwork (to make profile ci work) * Move *-types to node-graph/libraries folder * Add missing node migrations * Migrate more nodes * Remove impure memo node * More fixes and remove warning * Migrate context and add a few missing migrations --------- Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
23
node-graph/nodes/blending/Cargo.toml
Normal file
23
node-graph/nodes/blending/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "blending-nodes"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Blending operation nodes for Graphene"
|
||||
authors = ["Graphite Authors <contact@graphite.rs>"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[features]
|
||||
default = ["serde"]
|
||||
|
||||
[dependencies]
|
||||
# Local dependencies
|
||||
core-types = { workspace = true }
|
||||
vector-types = { workspace = true }
|
||||
graphic-types = { workspace = true }
|
||||
node-macro = { workspace = true }
|
||||
|
||||
# Workspace dependencies
|
||||
glam = { workspace = true }
|
||||
|
||||
# Optional workspace dependencies
|
||||
serde = { workspace = true, optional = true }
|
||||
256
node-graph/nodes/blending/src/lib.rs
Normal file
256
node-graph/nodes/blending/src/lib.rs
Normal file
@@ -0,0 +1,256 @@
|
||||
use core_types::registry::types::Percentage;
|
||||
use core_types::table::Table;
|
||||
use core_types::{BlendMode, Color, Ctx};
|
||||
use graphic_types::Graphic;
|
||||
use graphic_types::Vector;
|
||||
use graphic_types::raster_types::{CPU, Raster};
|
||||
use vector_types::GradientStops;
|
||||
|
||||
pub(crate) trait MultiplyAlpha {
|
||||
fn multiply_alpha(&mut self, factor: f64);
|
||||
}
|
||||
|
||||
impl MultiplyAlpha for Color {
|
||||
fn multiply_alpha(&mut self, factor: f64) {
|
||||
*self = Color::from_rgbaf32_unchecked(self.r(), self.g(), self.b(), (self.a() * factor as f32).clamp(0., 1.))
|
||||
}
|
||||
}
|
||||
impl MultiplyAlpha for Table<Vector> {
|
||||
fn multiply_alpha(&mut self, factor: f64) {
|
||||
for row in self.iter_mut() {
|
||||
row.alpha_blending.opacity *= factor as f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
impl MultiplyAlpha for Table<Graphic> {
|
||||
fn multiply_alpha(&mut self, factor: f64) {
|
||||
for row in self.iter_mut() {
|
||||
row.alpha_blending.opacity *= factor as f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
impl MultiplyAlpha for Table<Raster<CPU>> {
|
||||
fn multiply_alpha(&mut self, factor: f64) {
|
||||
for row in self.iter_mut() {
|
||||
row.alpha_blending.opacity *= factor as f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
impl MultiplyAlpha for Table<Color> {
|
||||
fn multiply_alpha(&mut self, factor: f64) {
|
||||
for row in self.iter_mut() {
|
||||
row.alpha_blending.opacity *= factor as f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
impl MultiplyAlpha for Table<GradientStops> {
|
||||
fn multiply_alpha(&mut self, factor: f64) {
|
||||
for row in self.iter_mut() {
|
||||
row.alpha_blending.opacity *= factor as f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) trait MultiplyFill {
|
||||
fn multiply_fill(&mut self, factor: f64);
|
||||
}
|
||||
impl MultiplyFill for Color {
|
||||
fn multiply_fill(&mut self, factor: f64) {
|
||||
*self = Color::from_rgbaf32_unchecked(self.r(), self.g(), self.b(), (self.a() * factor as f32).clamp(0., 1.))
|
||||
}
|
||||
}
|
||||
impl MultiplyFill for Table<Vector> {
|
||||
fn multiply_fill(&mut self, factor: f64) {
|
||||
for row in self.iter_mut() {
|
||||
row.alpha_blending.fill *= factor as f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
impl MultiplyFill for Table<Graphic> {
|
||||
fn multiply_fill(&mut self, factor: f64) {
|
||||
for row in self.iter_mut() {
|
||||
row.alpha_blending.fill *= factor as f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
impl MultiplyFill for Table<Raster<CPU>> {
|
||||
fn multiply_fill(&mut self, factor: f64) {
|
||||
for row in self.iter_mut() {
|
||||
row.alpha_blending.fill *= factor as f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
impl MultiplyFill for Table<Color> {
|
||||
fn multiply_fill(&mut self, factor: f64) {
|
||||
for row in self.iter_mut() {
|
||||
row.alpha_blending.fill *= factor as f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
impl MultiplyFill for Table<GradientStops> {
|
||||
fn multiply_fill(&mut self, factor: f64) {
|
||||
for row in self.iter_mut() {
|
||||
row.alpha_blending.fill *= factor as f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trait SetBlendMode {
|
||||
fn set_blend_mode(&mut self, blend_mode: BlendMode);
|
||||
}
|
||||
|
||||
impl SetBlendMode for Table<Vector> {
|
||||
fn set_blend_mode(&mut self, blend_mode: BlendMode) {
|
||||
for row in self.iter_mut() {
|
||||
row.alpha_blending.blend_mode = blend_mode;
|
||||
}
|
||||
}
|
||||
}
|
||||
impl SetBlendMode for Table<Graphic> {
|
||||
fn set_blend_mode(&mut self, blend_mode: BlendMode) {
|
||||
for row in self.iter_mut() {
|
||||
row.alpha_blending.blend_mode = blend_mode;
|
||||
}
|
||||
}
|
||||
}
|
||||
impl SetBlendMode for Table<Raster<CPU>> {
|
||||
fn set_blend_mode(&mut self, blend_mode: BlendMode) {
|
||||
for row in self.iter_mut() {
|
||||
row.alpha_blending.blend_mode = blend_mode;
|
||||
}
|
||||
}
|
||||
}
|
||||
impl SetBlendMode for Table<Color> {
|
||||
fn set_blend_mode(&mut self, blend_mode: BlendMode) {
|
||||
for row in self.iter_mut() {
|
||||
row.alpha_blending.blend_mode = blend_mode;
|
||||
}
|
||||
}
|
||||
}
|
||||
impl SetBlendMode for Table<GradientStops> {
|
||||
fn set_blend_mode(&mut self, blend_mode: BlendMode) {
|
||||
for row in self.iter_mut() {
|
||||
row.alpha_blending.blend_mode = blend_mode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trait SetClip {
|
||||
fn set_clip(&mut self, clip: bool);
|
||||
}
|
||||
|
||||
impl SetClip for Table<Vector> {
|
||||
fn set_clip(&mut self, clip: bool) {
|
||||
for row in self.iter_mut() {
|
||||
row.alpha_blending.clip = clip;
|
||||
}
|
||||
}
|
||||
}
|
||||
impl SetClip for Table<Graphic> {
|
||||
fn set_clip(&mut self, clip: bool) {
|
||||
for row in self.iter_mut() {
|
||||
row.alpha_blending.clip = clip;
|
||||
}
|
||||
}
|
||||
}
|
||||
impl SetClip for Table<Raster<CPU>> {
|
||||
fn set_clip(&mut self, clip: bool) {
|
||||
for row in self.iter_mut() {
|
||||
row.alpha_blending.clip = clip;
|
||||
}
|
||||
}
|
||||
}
|
||||
impl SetClip for Table<Color> {
|
||||
fn set_clip(&mut self, clip: bool) {
|
||||
for row in self.iter_mut() {
|
||||
row.alpha_blending.clip = clip;
|
||||
}
|
||||
}
|
||||
}
|
||||
impl SetClip for Table<GradientStops> {
|
||||
fn set_clip(&mut self, clip: bool) {
|
||||
for row in self.iter_mut() {
|
||||
row.alpha_blending.clip = clip;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies the blend mode to the input graphics. Setting this allows for customizing how overlapping content is composited together.
|
||||
#[node_macro::node(category("Style"))]
|
||||
fn blend_mode<T: SetBlendMode>(
|
||||
_: impl Ctx,
|
||||
/// The layer stack that will be composited when rendering.
|
||||
#[implementations(
|
||||
Table<Graphic>,
|
||||
Table<Vector>,
|
||||
Table<Raster<CPU>>,
|
||||
Table<Color>,
|
||||
Table<GradientStops>,
|
||||
)]
|
||||
mut content: T,
|
||||
/// The choice of equation that controls how brightness and color blends between overlapping pixels.
|
||||
blend_mode: BlendMode,
|
||||
) -> T {
|
||||
// TODO: Find a way to make this apply once to the table's parent (i.e. its row in its parent table or TableRow<T>) rather than applying to each row in its own table, which produces the undesired result
|
||||
content.set_blend_mode(blend_mode);
|
||||
content
|
||||
}
|
||||
|
||||
/// Modifies the opacity of the input graphics by multiplying the existing opacity by this percentage.
|
||||
/// This affects the transparency of the content (together with anything above which is clipped to it).
|
||||
#[node_macro::node(category("Style"))]
|
||||
fn opacity<T: MultiplyAlpha>(
|
||||
_: impl Ctx,
|
||||
/// The layer stack that will be composited when rendering.
|
||||
#[implementations(
|
||||
Table<Graphic>,
|
||||
Table<Vector>,
|
||||
Table<Raster<CPU>>,
|
||||
Table<Color>,
|
||||
Table<GradientStops>,
|
||||
)]
|
||||
mut content: T,
|
||||
/// How visible the content should be, including any content clipped to it.
|
||||
/// Ranges from the default of 100% (fully opaque) to 0% (fully transparent).
|
||||
#[default(100.)]
|
||||
opacity: Percentage,
|
||||
) -> T {
|
||||
// TODO: Find a way to make this apply once to the table's parent (i.e. its row in its parent table or TableRow<T>) rather than applying to each row in its own table, which produces the undesired result
|
||||
content.multiply_alpha(opacity / 100.);
|
||||
content
|
||||
}
|
||||
|
||||
/// Sets each of the blending properties at once. The blend mode determines how overlapping content is composited together. The opacity affects the transparency of the content (together with anything above which is clipped to it). The fill affects the transparency of the content itself, without affecting that of content clipped to it. The clip property determines whether the content inherits the alpha of the content beneath it.
|
||||
#[node_macro::node(category("Style"))]
|
||||
fn blending<T: SetBlendMode + MultiplyAlpha + MultiplyFill + SetClip>(
|
||||
_: impl Ctx,
|
||||
/// The layer stack that will be composited when rendering.
|
||||
#[implementations(
|
||||
Table<Graphic>,
|
||||
Table<Vector>,
|
||||
Table<Raster<CPU>>,
|
||||
Table<Color>,
|
||||
Table<GradientStops>,
|
||||
)]
|
||||
mut content: T,
|
||||
/// The choice of equation that controls how brightness and color blends between overlapping pixels.
|
||||
blend_mode: BlendMode,
|
||||
/// How visible the content should be, including any content clipped to it.
|
||||
/// Ranges from the default of 100% (fully opaque) to 0% (fully transparent).
|
||||
#[default(100.)]
|
||||
opacity: Percentage,
|
||||
/// How visible the content should be, independent of any content clipped to it.
|
||||
/// Ranges from 0% (fully transparent) to 100% (fully opaque).
|
||||
#[default(100.)]
|
||||
fill: Percentage,
|
||||
/// Whether the content inherits the alpha of the content beneath it.
|
||||
#[default(false)]
|
||||
clip: bool,
|
||||
) -> T {
|
||||
// TODO: Find a way to make this apply once to the table's parent (i.e. its row in its parent table or TableRow<T>) rather than applying to each row in its own table, which produces the undesired result
|
||||
content.set_blend_mode(blend_mode);
|
||||
content.multiply_alpha(opacity / 100.);
|
||||
content.multiply_fill(fill / 100.);
|
||||
content.set_clip(clip);
|
||||
content
|
||||
}
|
||||
29
node-graph/nodes/brush/Cargo.toml
Normal file
29
node-graph/nodes/brush/Cargo.toml
Normal file
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "brush-nodes"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Brush rendering nodes for Graphene"
|
||||
authors = ["Graphite Authors <contact@graphite.rs>"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[features]
|
||||
default = ["serde"]
|
||||
serde = ["dep:serde"]
|
||||
|
||||
[dependencies]
|
||||
# Local dependencies
|
||||
dyn-any = { workspace = true }
|
||||
core-types = { workspace = true }
|
||||
raster-types = { workspace = true }
|
||||
raster-nodes = { workspace = true }
|
||||
node-macro = { workspace = true }
|
||||
|
||||
# Workspace dependencies
|
||||
glam = { workspace = true }
|
||||
|
||||
# Optional workspace dependencies
|
||||
serde = { workspace = true, optional = true, features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# Workspace dependencies
|
||||
tokio = { workspace = true }
|
||||
412
node-graph/nodes/brush/src/brush.rs
Normal file
412
node-graph/nodes/brush/src/brush.rs
Normal file
@@ -0,0 +1,412 @@
|
||||
use crate::brush_cache::BrushCache;
|
||||
use crate::brush_stroke::{BrushStroke, BrushStyle};
|
||||
use core_types::blending::BlendMode;
|
||||
use core_types::bounds::{BoundingBox, RenderBoundingBox};
|
||||
use core_types::color::{Alpha, Color, Pixel, Sample};
|
||||
use core_types::generic::FnNode;
|
||||
use core_types::math::bbox::{AxisAlignedBbox, Bbox};
|
||||
use core_types::registry::FutureWrapperNode;
|
||||
use core_types::table::{Table, TableRow};
|
||||
use core_types::transform::Transform;
|
||||
use core_types::value::ClonedNode;
|
||||
use core_types::{Ctx, Node};
|
||||
use glam::{DAffine2, DVec2};
|
||||
use raster_nodes::blending_nodes::blend_colors;
|
||||
use raster_nodes::std_nodes::{empty_image, extend_image_to_bounds};
|
||||
use raster_types::BitmapMut;
|
||||
use raster_types::Image;
|
||||
use raster_types::{CPU, Raster};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct BrushStampGenerator<P: Pixel + Alpha> {
|
||||
color: P,
|
||||
feather_exponent: f32,
|
||||
transform: DAffine2,
|
||||
}
|
||||
|
||||
impl<P: Pixel + Alpha> Transform for BrushStampGenerator<P> {
|
||||
fn transform(&self) -> DAffine2 {
|
||||
self.transform
|
||||
}
|
||||
}
|
||||
|
||||
impl<P: Pixel + Alpha> Sample for BrushStampGenerator<P> {
|
||||
type Pixel = P;
|
||||
|
||||
#[inline]
|
||||
fn sample(&self, position: DVec2, area: DVec2) -> Option<P> {
|
||||
let position = self.transform.inverse().transform_point2(position);
|
||||
let area = self.transform.inverse().transform_vector2(area);
|
||||
let aa_blur_radius = area.length() as f32 * 2.;
|
||||
let center = DVec2::splat(0.5);
|
||||
|
||||
let distance = (position + area / 2. - center).length() as f32 * 2.;
|
||||
|
||||
let edge_opacity = 1. - (1. - aa_blur_radius).powf(self.feather_exponent);
|
||||
let result = if distance < 1. - aa_blur_radius {
|
||||
1. - distance.powf(self.feather_exponent)
|
||||
} else if distance < 1. {
|
||||
// TODO: Replace this with a proper analytical AA implementation
|
||||
edge_opacity * ((1. - distance) / aa_blur_radius)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
use core_types::color::Channel;
|
||||
Some(self.color.multiplied_alpha(P::AlphaChannel::from_linear(result)))
|
||||
}
|
||||
}
|
||||
|
||||
#[node_macro::node(skip_impl)]
|
||||
fn brush_stamp_generator(#[unit(" px")] diameter: f64, color: Color, hardness: f64, flow: f64) -> BrushStampGenerator<Color> {
|
||||
// Diameter
|
||||
let radius = diameter / 2.;
|
||||
|
||||
// Hardness
|
||||
let hardness = hardness / 100.;
|
||||
let feather_exponent = 1. / (1. - hardness) as f32;
|
||||
|
||||
// Flow
|
||||
let flow = flow / 100.;
|
||||
|
||||
// Color
|
||||
let color = color.apply_opacity(flow as f32);
|
||||
|
||||
let transform = DAffine2::from_scale_angle_translation(DVec2::splat(diameter), 0., -DVec2::splat(radius));
|
||||
BrushStampGenerator { color, feather_exponent, transform }
|
||||
}
|
||||
|
||||
#[node_macro::node(skip_impl)]
|
||||
fn blit<BlendFn>(mut target: Table<Raster<CPU>>, texture: Raster<CPU>, positions: Vec<DVec2>, blend_mode: BlendFn) -> Table<Raster<CPU>>
|
||||
where
|
||||
BlendFn: for<'any_input> Node<'any_input, (Color, Color), Output = Color>,
|
||||
{
|
||||
if positions.is_empty() {
|
||||
return target;
|
||||
}
|
||||
|
||||
for table_row in target.iter_mut() {
|
||||
let target_width = table_row.element.width;
|
||||
let target_height = table_row.element.height;
|
||||
let target_size = DVec2::new(target_width as f64, target_height as f64);
|
||||
|
||||
let texture_size = DVec2::new(texture.width as f64, texture.height as f64);
|
||||
|
||||
let document_to_target = DAffine2::from_translation(-texture_size / 2.) * DAffine2::from_scale(target_size) * table_row.transform.inverse();
|
||||
|
||||
for position in &positions {
|
||||
let start = document_to_target.transform_point2(*position).round();
|
||||
let stop = start + texture_size;
|
||||
|
||||
// Half-open integer ranges [start, stop).
|
||||
let clamp_start = start.clamp(DVec2::ZERO, target_size).as_uvec2();
|
||||
let clamp_stop = stop.clamp(DVec2::ZERO, target_size).as_uvec2();
|
||||
|
||||
let blit_area_offset = (clamp_start.as_dvec2() - start).as_uvec2().min(texture_size.as_uvec2());
|
||||
let blit_area_dimensions = (clamp_stop - clamp_start).min(texture_size.as_uvec2() - blit_area_offset);
|
||||
|
||||
// Tight blitting loop. Eagerly assert bounds to hopefully eliminate bounds check inside loop.
|
||||
let texture_index = |x: u32, y: u32| -> usize { (y as usize * texture.width as usize) + (x as usize) };
|
||||
let target_index = |x: u32, y: u32| -> usize { (y as usize * target_width as usize) + (x as usize) };
|
||||
|
||||
let max_y = (blit_area_offset.y + blit_area_dimensions.y).saturating_sub(1);
|
||||
let max_x = (blit_area_offset.x + blit_area_dimensions.x).saturating_sub(1);
|
||||
assert!(texture_index(max_x, max_y) < texture.data.len());
|
||||
assert!(target_index(max_x, max_y) < table_row.element.data.len());
|
||||
|
||||
for y in blit_area_offset.y..blit_area_offset.y + blit_area_dimensions.y {
|
||||
for x in blit_area_offset.x..blit_area_offset.x + blit_area_dimensions.x {
|
||||
let src_pixel = texture.data[texture_index(x, y)];
|
||||
let dst_pixel = &mut table_row.element.data_mut().data[target_index(x + clamp_start.x, y + clamp_start.y)];
|
||||
*dst_pixel = blend_mode.eval((src_pixel, *dst_pixel));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
target
|
||||
}
|
||||
|
||||
pub async fn create_brush_texture(brush_style: &BrushStyle) -> Raster<CPU> {
|
||||
let stamp = brush_stamp_generator(brush_style.diameter, brush_style.color, brush_style.hardness, brush_style.flow);
|
||||
let transform = DAffine2::from_scale_angle_translation(DVec2::splat(brush_style.diameter), 0., -DVec2::splat(brush_style.diameter / 2.));
|
||||
let blank_texture = empty_image((), transform, Table::new_from_element(Color::TRANSPARENT)).into_iter().next().unwrap_or_default();
|
||||
let image = blend_stamp_closure(stamp, blank_texture, |a, b| blend_colors(a, b, BlendMode::Normal, 1.));
|
||||
|
||||
image.element
|
||||
}
|
||||
|
||||
pub fn blend_with_mode(background: TableRow<Raster<CPU>>, foreground: TableRow<Raster<CPU>>, blend_mode: BlendMode, opacity: f64) -> TableRow<Raster<CPU>> {
|
||||
let opacity = opacity as f32 / 100.;
|
||||
match std::hint::black_box(blend_mode) {
|
||||
// Normal group
|
||||
BlendMode::Normal => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Normal, opacity)),
|
||||
// Darken group
|
||||
BlendMode::Darken => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Darken, opacity)),
|
||||
BlendMode::Multiply => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Multiply, opacity)),
|
||||
BlendMode::ColorBurn => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::ColorBurn, opacity)),
|
||||
BlendMode::LinearBurn => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::LinearBurn, opacity)),
|
||||
BlendMode::DarkerColor => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::DarkerColor, opacity)),
|
||||
// Lighten group
|
||||
BlendMode::Lighten => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Lighten, opacity)),
|
||||
BlendMode::Screen => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Screen, opacity)),
|
||||
BlendMode::ColorDodge => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::ColorDodge, opacity)),
|
||||
BlendMode::LinearDodge => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::LinearDodge, opacity)),
|
||||
BlendMode::LighterColor => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::LighterColor, opacity)),
|
||||
// Contrast group
|
||||
BlendMode::Overlay => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Overlay, opacity)),
|
||||
BlendMode::SoftLight => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::SoftLight, opacity)),
|
||||
BlendMode::HardLight => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::HardLight, opacity)),
|
||||
BlendMode::VividLight => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::VividLight, opacity)),
|
||||
BlendMode::LinearLight => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::LinearLight, opacity)),
|
||||
BlendMode::PinLight => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::PinLight, opacity)),
|
||||
BlendMode::HardMix => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::HardMix, opacity)),
|
||||
// Inversion group
|
||||
BlendMode::Difference => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Difference, opacity)),
|
||||
BlendMode::Exclusion => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Exclusion, opacity)),
|
||||
BlendMode::Subtract => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Subtract, opacity)),
|
||||
BlendMode::Divide => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Divide, opacity)),
|
||||
// Component group
|
||||
BlendMode::Hue => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Hue, opacity)),
|
||||
BlendMode::Saturation => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Saturation, opacity)),
|
||||
BlendMode::Color => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Color, opacity)),
|
||||
BlendMode::Luminosity => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Luminosity, opacity)),
|
||||
// Other utility blend modes (hidden from the normal list)
|
||||
BlendMode::Erase => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Erase, opacity)),
|
||||
BlendMode::Restore => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::Restore, opacity)),
|
||||
BlendMode::MultiplyAlpha => blend_image_closure(foreground, background, |a, b| blend_colors(a, b, BlendMode::MultiplyAlpha, opacity)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates the brush strokes painted with the Brush tool as a raster image.
|
||||
/// If an input image is supplied, strokes are drawn on top of it, expanding bounds as needed.
|
||||
#[node_macro::node(category("Raster"))]
|
||||
async fn brush(
|
||||
_: impl Ctx,
|
||||
/// Optional raster content that may be drawn onto.
|
||||
mut image: Table<Raster<CPU>>,
|
||||
/// The list of brush stroke paths drawn by the Brush tool, with each including both its coordinates and styles.
|
||||
strokes: Vec<BrushStroke>,
|
||||
/// Internal cache data used to accelerate rendering of the brush content.
|
||||
cache: BrushCache,
|
||||
) -> Table<Raster<CPU>> {
|
||||
if image.is_empty() {
|
||||
image.push(TableRow::default());
|
||||
}
|
||||
// TODO: Find a way to handle more than one row
|
||||
let table_row = image.iter().next().expect("Expected the one row we just pushed").into_cloned();
|
||||
|
||||
let bounds = Table::new_from_row(table_row.clone()).bounding_box(DAffine2::IDENTITY, false);
|
||||
let [start, end] = if let RenderBoundingBox::Rectangle(rect) = bounds { rect } else { [DVec2::ZERO, DVec2::ZERO] };
|
||||
let image_bbox = AxisAlignedBbox { start, end };
|
||||
let stroke_bbox = strokes.iter().map(|s| s.bounding_box()).reduce(|a, b| a.union(&b)).unwrap_or(AxisAlignedBbox::ZERO);
|
||||
let bbox = if image_bbox.size().length() < 0.1 { stroke_bbox } else { stroke_bbox.union(&image_bbox) };
|
||||
let background_bounds = bbox.to_transform();
|
||||
|
||||
let mut draw_strokes: Vec<_> = strokes.iter().filter(|&s| !matches!(s.style.blend_mode, BlendMode::Erase | BlendMode::Restore)).cloned().collect();
|
||||
|
||||
let mut brush_plan = cache.compute_brush_plan(table_row, &draw_strokes);
|
||||
|
||||
// TODO: Find a way to handle more than one row
|
||||
let Some(mut actual_image) = extend_image_to_bounds((), Table::new_from_row(brush_plan.background), background_bounds).into_iter().next() else {
|
||||
return Table::new();
|
||||
};
|
||||
|
||||
let final_stroke_idx = brush_plan.strokes.len().saturating_sub(1);
|
||||
for (idx, stroke) in brush_plan.strokes.into_iter().enumerate() {
|
||||
// Create brush texture.
|
||||
// TODO: apply rotation from layer to stamp for non-rotationally-symmetric brushes.
|
||||
let mut brush_texture = cache.get_cached_brush(&stroke.style);
|
||||
if brush_texture.is_none() {
|
||||
let tex = create_brush_texture(&stroke.style).await;
|
||||
cache.store_brush(stroke.style.clone(), tex.clone());
|
||||
brush_texture = Some(tex);
|
||||
}
|
||||
let brush_texture = brush_texture.unwrap();
|
||||
|
||||
// Compute transformation from stroke texture space into layer space, and create the stroke texture.
|
||||
let skip = if idx == 0 { brush_plan.first_stroke_point_skip } else { 0 };
|
||||
let positions: Vec<_> = stroke.compute_blit_points().into_iter().skip(skip).collect();
|
||||
let stroke_texture = if idx == 0 && positions.is_empty() {
|
||||
core::mem::take(&mut brush_plan.first_stroke_texture)
|
||||
} else {
|
||||
let mut bbox = stroke.bounding_box();
|
||||
bbox.start = bbox.start.floor();
|
||||
bbox.end = bbox.end.floor();
|
||||
let stroke_size = bbox.size() + DVec2::splat(stroke.style.diameter);
|
||||
// For numerical stability we want to place the first blit point at a stable, integer offset in layer space.
|
||||
let snap_offset = positions[0].floor() - positions[0];
|
||||
let stroke_origin_in_layer = bbox.start - snap_offset - DVec2::splat(stroke.style.diameter / 2.);
|
||||
let stroke_to_layer = DAffine2::from_translation(stroke_origin_in_layer) * DAffine2::from_scale(stroke_size);
|
||||
|
||||
let normal_blend = FnNode::new(|(a, b)| blend_colors(a, b, BlendMode::Normal, 1.));
|
||||
let blit_node = BlitNode::new(
|
||||
FutureWrapperNode::new(ClonedNode::new(brush_texture)),
|
||||
FutureWrapperNode::new(ClonedNode::new(positions)),
|
||||
FutureWrapperNode::new(ClonedNode::new(normal_blend)),
|
||||
);
|
||||
let blit_target = if idx == 0 {
|
||||
let target = core::mem::take(&mut brush_plan.first_stroke_texture);
|
||||
extend_image_to_bounds((), Table::new_from_row(target), stroke_to_layer)
|
||||
} else {
|
||||
empty_image((), stroke_to_layer, Table::new_from_element(Color::TRANSPARENT))
|
||||
// EmptyImageNode::new(CopiedNode::new(stroke_to_layer), CopiedNode::new(Color::TRANSPARENT)).eval(())
|
||||
};
|
||||
|
||||
let table = blit_node.eval(blit_target).await;
|
||||
assert_eq!(table.len(), 1);
|
||||
table.into_iter().next().unwrap_or_default()
|
||||
};
|
||||
|
||||
// Cache image before doing final blend, and store final stroke texture.
|
||||
if idx == final_stroke_idx {
|
||||
cache.cache_results(core::mem::take(&mut draw_strokes), actual_image.clone(), stroke_texture.clone());
|
||||
}
|
||||
|
||||
// TODO: Is this the correct way to do opacity in blending?
|
||||
actual_image = blend_with_mode(actual_image, stroke_texture, stroke.style.blend_mode, (stroke.style.color.a() * 100.) as f64);
|
||||
}
|
||||
|
||||
let has_erase_or_restore_strokes = strokes.iter().any(|s| matches!(s.style.blend_mode, BlendMode::Erase | BlendMode::Restore));
|
||||
if has_erase_or_restore_strokes {
|
||||
let opaque_image = Image::new(bbox.size().x as u32, bbox.size().y as u32, Color::WHITE);
|
||||
let mut erase_restore_mask = TableRow {
|
||||
element: Raster::new_cpu(opaque_image),
|
||||
transform: background_bounds,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for stroke in strokes {
|
||||
let mut brush_texture = cache.get_cached_brush(&stroke.style);
|
||||
if brush_texture.is_none() {
|
||||
let tex = create_brush_texture(&stroke.style).await;
|
||||
cache.store_brush(stroke.style.clone(), tex.clone());
|
||||
brush_texture = Some(tex);
|
||||
}
|
||||
let brush_texture = brush_texture.unwrap();
|
||||
let positions: Vec<_> = stroke.compute_blit_points().into_iter().collect();
|
||||
|
||||
// For mask composition: Erase subtracts alpha, Restore adds alpha, and Draw acts like Restore to allow repainting erased areas.
|
||||
let mask_blend_mode = match stroke.style.blend_mode {
|
||||
BlendMode::Erase => BlendMode::Erase,
|
||||
BlendMode::Restore => BlendMode::Restore,
|
||||
_ => BlendMode::Restore,
|
||||
};
|
||||
|
||||
let blend_params = FnNode::new(move |(a, b)| blend_colors(a, b, mask_blend_mode, 1.));
|
||||
let blit_node = BlitNode::new(
|
||||
FutureWrapperNode::new(ClonedNode::new(brush_texture)),
|
||||
FutureWrapperNode::new(ClonedNode::new(positions)),
|
||||
FutureWrapperNode::new(ClonedNode::new(blend_params)),
|
||||
);
|
||||
erase_restore_mask = blit_node.eval(Table::new_from_row(erase_restore_mask)).await.into_iter().next().unwrap_or_default();
|
||||
}
|
||||
|
||||
let blend_params = FnNode::new(|(a, b)| blend_colors(a, b, BlendMode::MultiplyAlpha, 1.));
|
||||
actual_image = blend_image_closure(erase_restore_mask, actual_image, |a, b| blend_params.eval((a, b)));
|
||||
}
|
||||
|
||||
let first_row = image.iter_mut().next().unwrap();
|
||||
*first_row.element = actual_image.element;
|
||||
*first_row.transform = actual_image.transform;
|
||||
*first_row.alpha_blending = actual_image.alpha_blending;
|
||||
*first_row.source_node_id = actual_image.source_node_id;
|
||||
|
||||
image
|
||||
}
|
||||
|
||||
pub fn blend_image_closure(foreground: TableRow<Raster<CPU>>, mut background: TableRow<Raster<CPU>>, map_fn: impl Fn(Color, Color) -> Color) -> TableRow<Raster<CPU>> {
|
||||
let foreground_size = DVec2::new(foreground.element.width as f64, foreground.element.height as f64);
|
||||
let background_size = DVec2::new(background.element.width as f64, background.element.height as f64);
|
||||
|
||||
// Transforms a point from the background image to the foreground image
|
||||
let background_to_foreground = DAffine2::from_scale(foreground_size) * foreground.transform.inverse() * background.transform * DAffine2::from_scale(1. / background_size);
|
||||
|
||||
// Footprint of the foreground image (0, 0)..(1, 1) in the background image space
|
||||
let background_aabb = Bbox::unit().affine_transform(background.transform.inverse() * foreground.transform).to_axis_aligned_bbox();
|
||||
|
||||
// Clamp the foreground image to the background image
|
||||
let start = (background_aabb.start * background_size).max(DVec2::ZERO).as_uvec2();
|
||||
let end = (background_aabb.end * background_size).min(background_size).as_uvec2();
|
||||
|
||||
for y in start.y..end.y {
|
||||
for x in start.x..end.x {
|
||||
let background_point = DVec2::new(x as f64, y as f64);
|
||||
let foreground_point = background_to_foreground.transform_point2(background_point);
|
||||
|
||||
let source_pixel = foreground.element.sample(foreground_point);
|
||||
let Some(destination_pixel) = background.element.data_mut().get_pixel_mut(x, y) else { continue };
|
||||
|
||||
*destination_pixel = map_fn(source_pixel, *destination_pixel);
|
||||
}
|
||||
}
|
||||
|
||||
background
|
||||
}
|
||||
|
||||
pub fn blend_stamp_closure(foreground: BrushStampGenerator<Color>, mut background: TableRow<Raster<CPU>>, map_fn: impl Fn(Color, Color) -> Color) -> TableRow<Raster<CPU>> {
|
||||
let background_size = DVec2::new(background.element.width as f64, background.element.height as f64);
|
||||
|
||||
// Transforms a point from the background image to the foreground image
|
||||
let background_to_foreground = background.transform * DAffine2::from_scale(1. / background_size);
|
||||
|
||||
// Footprint of the foreground image (0, 0)..(1, 1) in the background image space
|
||||
let background_aabb = Bbox::unit().affine_transform(background.transform.inverse() * foreground.transform).to_axis_aligned_bbox();
|
||||
|
||||
// Clamp the foreground image to the background image
|
||||
let start = (background_aabb.start * background_size).max(DVec2::ZERO).as_uvec2();
|
||||
let end = (background_aabb.end * background_size).min(background_size).as_uvec2();
|
||||
|
||||
let area = background_to_foreground.transform_point2(DVec2::new(1., 1.)) - background_to_foreground.transform_point2(DVec2::ZERO);
|
||||
for y in start.y..end.y {
|
||||
for x in start.x..end.x {
|
||||
let background_point = DVec2::new(x as f64, y as f64);
|
||||
let foreground_point = background_to_foreground.transform_point2(background_point);
|
||||
|
||||
let Some(source_pixel) = foreground.sample(foreground_point, area) else { continue };
|
||||
let Some(destination_pixel) = background.element.data_mut().get_pixel_mut(x, y) else { continue };
|
||||
|
||||
*destination_pixel = map_fn(source_pixel, *destination_pixel);
|
||||
}
|
||||
}
|
||||
|
||||
background
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use core_types::transform::Transform;
|
||||
use glam::DAffine2;
|
||||
|
||||
#[test]
|
||||
fn test_brush_texture() {
|
||||
let size = 20.;
|
||||
let image = brush_stamp_generator(size, Color::BLACK, 100., 100.);
|
||||
assert_eq!(image.transform(), DAffine2::from_scale_angle_translation(DVec2::splat(size.ceil()), 0., -DVec2::splat(size / 2.)));
|
||||
// center pixel should be BLACK
|
||||
assert_eq!(image.sample(DVec2::splat(0.), DVec2::ONE), Some(Color::BLACK));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_brush_output_size() {
|
||||
let image = brush(
|
||||
(),
|
||||
Table::new_from_element(Raster::new_cpu(Image::<Color>::default())),
|
||||
vec![BrushStroke {
|
||||
trace: vec![crate::brush_stroke::BrushInputSample { position: DVec2::ZERO }],
|
||||
style: BrushStyle {
|
||||
color: Color::BLACK,
|
||||
diameter: 20.,
|
||||
hardness: 20.,
|
||||
flow: 20.,
|
||||
spacing: 20.,
|
||||
blend_mode: BlendMode::Normal,
|
||||
},
|
||||
}],
|
||||
BrushCache::default(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(image.iter().next().unwrap().element.width, 20);
|
||||
}
|
||||
}
|
||||
188
node-graph/nodes/brush/src/brush_cache.rs
Normal file
188
node-graph/nodes/brush/src/brush_cache.rs
Normal file
@@ -0,0 +1,188 @@
|
||||
use crate::brush_stroke::BrushStroke;
|
||||
use crate::brush_stroke::BrushStyle;
|
||||
use core_types::table::TableRow;
|
||||
use dyn_any::DynAny;
|
||||
use raster_types::CPU;
|
||||
use raster_types::Raster;
|
||||
use std::collections::HashMap;
|
||||
use std::hash::Hash;
|
||||
use std::hash::Hasher;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
// TODO: This is a temporary hack, be sure to not reuse this when the brush system is replaced/rewritten.
|
||||
static NEXT_BRUSH_CACHE_IMPL_ID: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[derive(Clone, Debug, DynAny, serde::Serialize, serde::Deserialize)]
|
||||
struct BrushCacheImpl {
|
||||
#[serde(default = "new_unique_id")]
|
||||
unique_id: u64,
|
||||
// The full previous input that was cached.
|
||||
#[serde(default)]
|
||||
prev_input: Vec<BrushStroke>,
|
||||
|
||||
// The strokes that have been fully processed and blended into the background.
|
||||
#[serde(default, deserialize_with = "raster_types::image::migrate_image_frame_row")]
|
||||
background: TableRow<Raster<CPU>>,
|
||||
#[serde(default, deserialize_with = "raster_types::image::migrate_image_frame_row")]
|
||||
blended_image: TableRow<Raster<CPU>>,
|
||||
#[serde(default, deserialize_with = "raster_types::image::migrate_image_frame_row")]
|
||||
last_stroke_texture: TableRow<Raster<CPU>>,
|
||||
|
||||
// A cache for brush textures.
|
||||
#[serde(skip)]
|
||||
brush_texture_cache: HashMap<BrushStyle, Raster<CPU>>,
|
||||
}
|
||||
|
||||
impl BrushCacheImpl {
|
||||
fn compute_brush_plan(&mut self, mut background: TableRow<Raster<CPU>>, input: &[BrushStroke]) -> BrushPlan {
|
||||
// Do background invalidation.
|
||||
if background != self.background {
|
||||
self.background = background.clone();
|
||||
return BrushPlan {
|
||||
strokes: input.to_vec(),
|
||||
background,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
|
||||
// Do blended_image invalidation.
|
||||
let blended_strokes = &self.prev_input[..self.prev_input.len().saturating_sub(1)];
|
||||
let num_blended_strokes = blended_strokes.len();
|
||||
if input.get(..num_blended_strokes) != Some(blended_strokes) {
|
||||
return BrushPlan {
|
||||
strokes: input.to_vec(),
|
||||
background,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
|
||||
// Take our previous blended image (and invalidate the cache).
|
||||
// Since we're about to replace our cache anyway, this saves a clone.
|
||||
background = std::mem::take(&mut self.blended_image);
|
||||
|
||||
// Check if the first non-blended stroke is an extension of the last one.
|
||||
let mut first_stroke_texture = TableRow {
|
||||
element: Raster::<CPU>::default(),
|
||||
transform: glam::DAffine2::ZERO,
|
||||
..Default::default()
|
||||
};
|
||||
let mut first_stroke_point_skip = 0;
|
||||
let strokes = input[num_blended_strokes..].to_vec();
|
||||
if !strokes.is_empty() && self.prev_input.len() > num_blended_strokes {
|
||||
let last_stroke = &self.prev_input[num_blended_strokes];
|
||||
let same_style = strokes[0].style == last_stroke.style;
|
||||
let prev_points = last_stroke.compute_blit_points();
|
||||
let new_points = strokes[0].compute_blit_points();
|
||||
let is_point_prefix = new_points.get(..prev_points.len()) == Some(&prev_points);
|
||||
if same_style && is_point_prefix {
|
||||
first_stroke_texture = std::mem::take(&mut self.last_stroke_texture);
|
||||
first_stroke_point_skip = prev_points.len();
|
||||
}
|
||||
}
|
||||
|
||||
self.prev_input = Vec::new();
|
||||
BrushPlan {
|
||||
strokes,
|
||||
background,
|
||||
first_stroke_texture,
|
||||
first_stroke_point_skip,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cache_results(&mut self, input: Vec<BrushStroke>, blended_image: TableRow<Raster<CPU>>, last_stroke_texture: TableRow<Raster<CPU>>) {
|
||||
self.prev_input = input;
|
||||
self.blended_image = blended_image;
|
||||
self.last_stroke_texture = last_stroke_texture;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BrushCacheImpl {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
unique_id: new_unique_id(),
|
||||
prev_input: Vec::new(),
|
||||
background: Default::default(),
|
||||
blended_image: Default::default(),
|
||||
last_stroke_texture: Default::default(),
|
||||
brush_texture_cache: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for BrushCacheImpl {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.unique_id == other.unique_id
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for BrushCacheImpl {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.unique_id.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
fn new_unique_id() -> u64 {
|
||||
NEXT_BRUSH_CACHE_IMPL_ID.fetch_add(1, Ordering::SeqCst)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct BrushPlan {
|
||||
pub strokes: Vec<BrushStroke>,
|
||||
pub background: TableRow<Raster<CPU>>,
|
||||
pub first_stroke_texture: TableRow<Raster<CPU>>,
|
||||
pub first_stroke_point_skip: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, DynAny, serde::Serialize, serde::Deserialize)]
|
||||
pub struct BrushCache(Arc<Mutex<BrushCacheImpl>>);
|
||||
|
||||
// A bit of a cursed implementation to work around the current node system.
|
||||
// The original object is a 'prototype' that when cloned gives you a independent
|
||||
// new object. Any further clones however are all the same underlying cache object.
|
||||
impl Clone for BrushCache {
|
||||
fn clone(&self) -> Self {
|
||||
Self(Arc::new(Mutex::new(self.0.lock().unwrap().clone())))
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for BrushCache {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
if Arc::ptr_eq(&self.0, &other.0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let s = self.0.lock().unwrap();
|
||||
let o = other.0.lock().unwrap();
|
||||
|
||||
*s == *o
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for BrushCache {
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
self.0.lock().unwrap().hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl BrushCache {
|
||||
pub fn compute_brush_plan(&self, background: TableRow<Raster<CPU>>, input: &[BrushStroke]) -> BrushPlan {
|
||||
let mut inner = self.0.lock().unwrap();
|
||||
inner.compute_brush_plan(background, input)
|
||||
}
|
||||
|
||||
pub fn cache_results(&self, input: Vec<BrushStroke>, blended_image: TableRow<Raster<CPU>>, last_stroke_texture: TableRow<Raster<CPU>>) {
|
||||
let mut inner = self.0.lock().unwrap();
|
||||
inner.cache_results(input, blended_image, last_stroke_texture)
|
||||
}
|
||||
|
||||
pub fn get_cached_brush(&self, style: &BrushStyle) -> Option<Raster<CPU>> {
|
||||
let inner = self.0.lock().unwrap();
|
||||
inner.brush_texture_cache.get(style).cloned()
|
||||
}
|
||||
|
||||
pub fn store_brush(&self, style: BrushStyle, brush: Raster<CPU>) {
|
||||
let mut inner = self.0.lock().unwrap();
|
||||
inner.brush_texture_cache.insert(style, brush);
|
||||
}
|
||||
}
|
||||
127
node-graph/nodes/brush/src/brush_stroke.rs
Normal file
127
node-graph/nodes/brush/src/brush_stroke.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
use core_types::blending::BlendMode;
|
||||
use core_types::color::Color;
|
||||
use core_types::math::bbox::AxisAlignedBbox;
|
||||
use dyn_any::DynAny;
|
||||
use glam::DVec2;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
/// The style of a brush.
|
||||
#[derive(Clone, Debug, DynAny, serde::Serialize, serde::Deserialize)]
|
||||
pub struct BrushStyle {
|
||||
pub color: Color,
|
||||
pub diameter: f64,
|
||||
pub hardness: f64,
|
||||
pub flow: f64,
|
||||
pub spacing: f64, // Spacing as a fraction of the diameter.
|
||||
pub blend_mode: BlendMode,
|
||||
}
|
||||
|
||||
impl Default for BrushStyle {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
color: Color::BLACK,
|
||||
diameter: 40.,
|
||||
hardness: 50.,
|
||||
flow: 100.,
|
||||
spacing: 50., // Percentage of diameter.
|
||||
blend_mode: BlendMode::Normal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for BrushStyle {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.color.hash(state);
|
||||
self.diameter.to_bits().hash(state);
|
||||
self.hardness.to_bits().hash(state);
|
||||
self.flow.to_bits().hash(state);
|
||||
self.spacing.to_bits().hash(state);
|
||||
self.blend_mode.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for BrushStyle {}
|
||||
|
||||
impl PartialEq for BrushStyle {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.color == other.color
|
||||
&& self.diameter.to_bits() == other.diameter.to_bits()
|
||||
&& self.hardness.to_bits() == other.hardness.to_bits()
|
||||
&& self.flow.to_bits() == other.flow.to_bits()
|
||||
&& self.spacing.to_bits() == other.spacing.to_bits()
|
||||
&& self.blend_mode == other.blend_mode
|
||||
}
|
||||
}
|
||||
|
||||
/// A single sample of brush parameters across the brush stroke.
|
||||
#[derive(Clone, Debug, PartialEq, DynAny, serde::Serialize, serde::Deserialize)]
|
||||
pub struct BrushInputSample {
|
||||
// The position of the sample in layer space, in pixels.
|
||||
// The origin of layer space is not specified.
|
||||
pub position: DVec2,
|
||||
// Future work: pressure, stylus angle, etc.
|
||||
}
|
||||
|
||||
impl Hash for BrushInputSample {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.position.x.to_bits().hash(state);
|
||||
self.position.y.to_bits().hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
/// The parameters for a single stroke brush.
|
||||
#[derive(Clone, Debug, PartialEq, Hash, Default, DynAny, serde::Serialize, serde::Deserialize)]
|
||||
pub struct BrushStroke {
|
||||
pub style: BrushStyle,
|
||||
pub trace: Vec<BrushInputSample>,
|
||||
}
|
||||
|
||||
impl BrushStroke {
|
||||
pub fn bounding_box(&self) -> AxisAlignedBbox {
|
||||
let radius = self.style.diameter / 2.;
|
||||
self.compute_blit_points()
|
||||
.iter()
|
||||
.map(|pos| AxisAlignedBbox {
|
||||
start: *pos + DVec2::new(-radius, -radius),
|
||||
end: *pos + DVec2::new(radius, radius),
|
||||
})
|
||||
.reduce(|a, b| a.union(&b))
|
||||
.unwrap_or(AxisAlignedBbox::ZERO)
|
||||
}
|
||||
|
||||
pub fn compute_blit_points(&self) -> Vec<DVec2> {
|
||||
// We always travel in a straight line towards the next user input,
|
||||
// placing a blit point every time we travelled our spacing distance.
|
||||
let spacing_dist = self.style.spacing / 100. * self.style.diameter;
|
||||
|
||||
let Some(first_sample) = self.trace.first() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut cur_pos = first_sample.position;
|
||||
let mut result = vec![cur_pos];
|
||||
let mut dist_until_next_blit = spacing_dist;
|
||||
for sample in &self.trace[1..] {
|
||||
// Travel to the next sample.
|
||||
let delta = sample.position - cur_pos;
|
||||
let mut dist_left = delta.length();
|
||||
let unit_step = delta / dist_left;
|
||||
|
||||
while dist_left >= dist_until_next_blit {
|
||||
// Take a step to the next blit point.
|
||||
cur_pos += dist_until_next_blit * unit_step;
|
||||
dist_left -= dist_until_next_blit;
|
||||
|
||||
// Blit.
|
||||
result.push(cur_pos);
|
||||
dist_until_next_blit = spacing_dist;
|
||||
}
|
||||
|
||||
// Take the partial step to land at the sample.
|
||||
dist_until_next_blit -= dist_left;
|
||||
cur_pos = sample.position;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
3
node-graph/nodes/brush/src/lib.rs
Normal file
3
node-graph/nodes/brush/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod brush;
|
||||
pub mod brush_cache;
|
||||
pub mod brush_stroke;
|
||||
27
node-graph/nodes/gcore/Cargo.toml
Normal file
27
node-graph/nodes/gcore/Cargo.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "graphene-core"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Core utility nodes for Graphene"
|
||||
authors = ["Graphite Authors <contact@graphite.rs>"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[features]
|
||||
default = ["serde"]
|
||||
|
||||
[dependencies]
|
||||
# Local dependencies
|
||||
core-types = { workspace = true }
|
||||
raster-types = { workspace = true }
|
||||
graphic-types = { workspace = true }
|
||||
node-macro = { workspace = true }
|
||||
|
||||
# Workspace dependencies
|
||||
dyn-any = { workspace = true }
|
||||
glam = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
specta = { workspace = true }
|
||||
|
||||
# Optional workspace dependencies
|
||||
serde = { workspace = true, optional = true }
|
||||
58
node-graph/nodes/gcore/src/animation.rs
Normal file
58
node-graph/nodes/gcore/src/animation.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use core_types::{Ctx, ExtractAnimationTime, ExtractRealTime};
|
||||
|
||||
const DAY: f64 = 1000. * 3600. * 24.;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, dyn_any::DynAny, Default, Hash, node_macro::ChoiceType, serde::Serialize, serde::Deserialize)]
|
||||
pub enum RealTimeMode {
|
||||
#[label("UTC")]
|
||||
Utc,
|
||||
Year,
|
||||
Hour,
|
||||
Minute,
|
||||
#[default]
|
||||
Second,
|
||||
Millisecond,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AnimationTimeMode {
|
||||
AnimationTime,
|
||||
FrameNumber,
|
||||
}
|
||||
|
||||
/// Produces a chosen representation of the current real time and date (in UTC) based on the system clock.
|
||||
#[node_macro::node(category("Animation"))]
|
||||
fn real_time(
|
||||
ctx: impl Ctx + ExtractRealTime,
|
||||
_primary: (),
|
||||
/// The time and date component to be produced as a number.
|
||||
component: RealTimeMode,
|
||||
) -> f64 {
|
||||
let real_time = ctx.try_real_time().unwrap_or_default();
|
||||
// TODO: Implement proper conversion using and existing time implementation
|
||||
match component {
|
||||
RealTimeMode::Utc => real_time,
|
||||
RealTimeMode::Year => (real_time / DAY / 365.25).floor() + 1970., // TODO: Factor in a chosen timezone
|
||||
RealTimeMode::Hour => (real_time / 1000. / 3600.).floor() % 24., // TODO: Factor in a chosen timezone
|
||||
RealTimeMode::Minute => (real_time / 1000. / 60.).floor() % 60., // TODO: Factor in a chosen timezone
|
||||
|
||||
RealTimeMode::Second => (real_time / 1000.).floor() % 60.,
|
||||
RealTimeMode::Millisecond => real_time % 1000.,
|
||||
}
|
||||
}
|
||||
|
||||
/// Produces the time, in seconds on the timeline, since the beginning of animation playback.
|
||||
#[node_macro::node(category("Animation"))]
|
||||
fn animation_time(ctx: impl Ctx + ExtractAnimationTime) -> f64 {
|
||||
ctx.try_animation_time().unwrap_or_default()
|
||||
}
|
||||
|
||||
// TODO: These nodes require more sophisticated algorithms for giving the correct result
|
||||
// #[node_macro::node(category("Animation"))]
|
||||
// fn month(ctx: impl Ctx + ExtractRealTime) -> f64 {
|
||||
// ((ctx.try_real_time().unwrap_or_default() / DAY / 365.25 % 1.) * 12.).floor()
|
||||
// }
|
||||
// #[node_macro::node(category("Animation"))]
|
||||
// fn day(ctx: impl Ctx + ExtractRealTime) -> f64 {
|
||||
// (ctx.try_real_time().unwrap_or_default() / DAY
|
||||
// }
|
||||
123
node-graph/nodes/gcore/src/context_modification.rs
Normal file
123
node-graph/nodes/gcore/src/context_modification.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
use core::f64;
|
||||
use core_types::context::{CloneVarArgs, Context, ContextFeatures, Ctx, ExtractAll};
|
||||
use core_types::table::Table;
|
||||
use core_types::transform::Footprint;
|
||||
use core_types::uuid::NodeId;
|
||||
use core_types::{Color, OwnedContextImpl};
|
||||
use glam::{DAffine2, DVec2};
|
||||
use graphic_types::{Artboard, Graphic, Vector, vector_types::GradientStops};
|
||||
use raster_types::{CPU, GPU, Raster};
|
||||
|
||||
/// Filters out what should be unused components of the context based on the specified requirements.
|
||||
/// This node is inserted by the compiler to "zero out" unused context components.
|
||||
#[node_macro::node(category("Internal"))]
|
||||
async fn context_modification<T>(
|
||||
ctx: impl Ctx + CloneVarArgs + ExtractAll,
|
||||
/// The data to pass through, evaluated with the stripped down context.
|
||||
#[implementations(
|
||||
Context -> (),
|
||||
Context -> bool,
|
||||
Context -> u32,
|
||||
Context -> u64,
|
||||
Context -> f32,
|
||||
Context -> f64,
|
||||
Context -> String,
|
||||
Context -> DAffine2,
|
||||
Context -> Footprint,
|
||||
Context -> DVec2,
|
||||
Context -> Vec<DVec2>,
|
||||
Context -> Vec<NodeId>,
|
||||
Context -> Vec<f64>,
|
||||
Context -> Vec<f32>,
|
||||
Context -> Vec<String>,
|
||||
Context -> Table<Vector>,
|
||||
Context -> Table<Graphic>,
|
||||
Context -> Table<Raster<CPU>>,
|
||||
Context -> Table<Raster<GPU>>,
|
||||
Context -> Table<Color>,
|
||||
Context -> Table<Artboard>,
|
||||
Context -> Table<GradientStops>,
|
||||
Context -> GradientStops,
|
||||
)]
|
||||
value: impl Node<Context<'static>, Output = T>,
|
||||
/// The parts of the context to keep when evaluating the input value. All other parts are nullified.
|
||||
features_to_keep: ContextFeatures,
|
||||
) -> T {
|
||||
let new_context = OwnedContextImpl::from_flags(ctx, features_to_keep);
|
||||
|
||||
value.eval(Some(new_context.into())).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use core_types::transform::Footprint;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
/// Test that the hash of a nullified context remains stable even when nullified inputs change
|
||||
#[test]
|
||||
fn test_nullified_context_hash_stability() {
|
||||
use core_types::Context;
|
||||
use std::sync::Arc;
|
||||
|
||||
// Create original contexts using the Context type (Option<Arc<OwnedContextImpl>>)
|
||||
let original_ctx: Context = Some(Arc::new(
|
||||
OwnedContextImpl::empty()
|
||||
.with_footprint(Footprint::default())
|
||||
.with_index(1)
|
||||
.with_real_time(10.5)
|
||||
.with_vararg(Box::new("test"))
|
||||
.with_animation_time(20.25),
|
||||
));
|
||||
|
||||
// Test nullifying different features - hash should remain stable for each nullification
|
||||
let features_to_keep = ContextFeatures::empty(); // Nullify everything
|
||||
|
||||
// Create nullified context - this should only keep features specified in features_to_keep
|
||||
let nullified_ctx = OwnedContextImpl::from_flags(original_ctx.clone().unwrap(), features_to_keep);
|
||||
|
||||
// Calculate hash of nullified context
|
||||
let mut hasher1 = DefaultHasher::new();
|
||||
nullified_ctx.hash(&mut hasher1);
|
||||
let hash1 = hasher1.finish();
|
||||
|
||||
// Create a different original context with changed values
|
||||
let changed_ctx: Context = Some(Arc::new(
|
||||
OwnedContextImpl::empty()
|
||||
.with_footprint(Footprint::default()) // Same footprint
|
||||
.with_index(2)
|
||||
.with_real_time(999.9) // Different real time
|
||||
.with_vararg(Box::new("test"))
|
||||
.with_animation_time(888.8), // Different animation time
|
||||
));
|
||||
|
||||
// Create nullified context from the changed original - should have same hash since everything is nullified
|
||||
let nullified_changed_ctx = OwnedContextImpl::from_flags(changed_ctx.clone().unwrap(), features_to_keep);
|
||||
|
||||
let mut hasher2 = DefaultHasher::new();
|
||||
nullified_changed_ctx.hash(&mut hasher2);
|
||||
let hash2 = hasher2.finish();
|
||||
|
||||
// Hash should be the same because all features were nullified
|
||||
assert_eq!(hash1, hash2, "Hash of nullified context should remain stable regardless of input changes when features are nullified");
|
||||
|
||||
// Test partial nullification - keep only footprint
|
||||
let partial_features = ContextFeatures::FOOTPRINT | ContextFeatures::VARARGS;
|
||||
|
||||
let partial_nullified1 = OwnedContextImpl::from_flags(original_ctx.clone().unwrap(), partial_features);
|
||||
let partial_nullified2 = OwnedContextImpl::from_flags(changed_ctx.clone().unwrap(), partial_features);
|
||||
|
||||
let mut hasher3 = DefaultHasher::new();
|
||||
partial_nullified1.hash(&mut hasher3);
|
||||
let hash3 = hasher3.finish();
|
||||
|
||||
let mut hasher4 = DefaultHasher::new();
|
||||
partial_nullified2.hash(&mut hasher4);
|
||||
let hash4 = hasher4.finish();
|
||||
|
||||
// These should be the same because both have the same footprint (Footprint::default()) and varargs
|
||||
// and other features are nullified
|
||||
assert_eq!(hash3, hash4, "Hash should be stable when keeping only footprint and footprint values are the same");
|
||||
}
|
||||
}
|
||||
36
node-graph/nodes/gcore/src/debug.rs
Normal file
36
node-graph/nodes/gcore/src/debug.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use core_types::Ctx;
|
||||
use core_types::table::Table;
|
||||
use glam::{DAffine2, DVec2};
|
||||
use raster_types::{CPU, Raster};
|
||||
|
||||
/// Meant for debugging purposes, not general use. Logs the input value to the console and passes it through unchanged.
|
||||
#[node_macro::node(category("Debug"), name("Log to Console"))]
|
||||
fn log_to_console<T: std::fmt::Debug>(_: impl Ctx, #[implementations(bool, f64, u32, u64, DVec2, DAffine2, String)] value: T) -> T {
|
||||
// KEEP THIS `debug!()` - It acts as the output for the debug node itself
|
||||
log::debug!("{value:#?}");
|
||||
value
|
||||
}
|
||||
|
||||
/// Meant for debugging purposes, not general use. Returns the size of the input type in bytes.
|
||||
#[node_macro::node(category("Debug"))]
|
||||
fn size_of(_: impl Ctx, ty: core_types::Type) -> Option<usize> {
|
||||
ty.size()
|
||||
}
|
||||
|
||||
/// Meant for debugging purposes, not general use. Wraps the input value in the Some variant of an Option.
|
||||
#[node_macro::node(category("Debug"))]
|
||||
fn some<T>(_: impl Ctx, #[implementations(f64, f32, u32, u64, String)] input: T) -> Option<T> {
|
||||
Some(input)
|
||||
}
|
||||
|
||||
/// Meant for debugging purposes, not general use. Unwraps the input value from an Option, returning the default value if the input is None.
|
||||
#[node_macro::node(category("Debug"))]
|
||||
fn unwrap_option<T: Default>(_: impl Ctx, #[implementations(Option<f64>, Option<u32>, Option<u64>, Option<String>)] input: Option<T>) -> T {
|
||||
input.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Meant for debugging purposes, not general use. Clones the input value.
|
||||
#[node_macro::node(category("Debug"))]
|
||||
fn clone<'i, T: Clone + 'i>(_: impl Ctx, #[implementations(&Table<Raster<CPU>>)] value: &'i T) -> T {
|
||||
value.clone()
|
||||
}
|
||||
23
node-graph/nodes/gcore/src/extract_xy.rs
Normal file
23
node-graph/nodes/gcore/src/extract_xy.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use core_types::Ctx;
|
||||
use dyn_any::DynAny;
|
||||
use glam::{DVec2, IVec2, UVec2};
|
||||
|
||||
/// Obtains the X or Y component of a vec2.
|
||||
///
|
||||
/// The inverse of this node is "Vec2 Value", which can have either or both its X and Y parameters exposed as graph inputs.
|
||||
#[node_macro::node(name("Extract XY"), category("Math: Vector"))]
|
||||
fn extract_xy<T: Into<DVec2>>(_: impl Ctx, #[implementations(DVec2, IVec2, UVec2)] vector: T, axis: XY) -> f64 {
|
||||
match axis {
|
||||
XY::X => vector.into().x,
|
||||
XY::Y => vector.into().y,
|
||||
}
|
||||
}
|
||||
|
||||
/// The X or Y component of a vec2.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, DynAny, node_macro::ChoiceType, specta::Type, serde::Serialize, serde::Deserialize)]
|
||||
#[widget(Radio)]
|
||||
pub enum XY {
|
||||
#[default]
|
||||
X,
|
||||
Y,
|
||||
}
|
||||
16
node-graph/nodes/gcore/src/lib.rs
Normal file
16
node-graph/nodes/gcore/src/lib.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
pub mod animation;
|
||||
pub mod context_modification;
|
||||
pub mod debug;
|
||||
pub mod extract_xy;
|
||||
pub mod logic;
|
||||
pub mod memo;
|
||||
pub mod ops;
|
||||
|
||||
// Re-export all nodes
|
||||
pub use animation::*;
|
||||
pub use context_modification::*;
|
||||
pub use debug::*;
|
||||
pub use extract_xy::*;
|
||||
pub use logic::*;
|
||||
pub use memo::*;
|
||||
pub use ops::*;
|
||||
138
node-graph/nodes/gcore/src/logic.rs
Normal file
138
node-graph/nodes/gcore/src/logic.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
use core_types::Color;
|
||||
use core_types::registry::types::TextArea;
|
||||
use core_types::table::Table;
|
||||
use core_types::{Context, Ctx};
|
||||
use glam::{DAffine2, DVec2};
|
||||
use graphic_types::{Artboard, Graphic, Vector, vector_types::GradientStops};
|
||||
use raster_types::{CPU, GPU, Raster};
|
||||
|
||||
/// Type-asserts a value to be a string.
|
||||
#[node_macro::node(category("Debug"))]
|
||||
fn to_string(_: impl Ctx, value: String) -> String {
|
||||
value
|
||||
}
|
||||
|
||||
/// Converts a value to a JSON string representation.
|
||||
#[node_macro::node(category("Text"))]
|
||||
fn serialize<T: serde::Serialize>(
|
||||
_: impl Ctx,
|
||||
#[implementations(String, bool, f64, u32, u64, DVec2, DAffine2, /* Table<Artboard>, Table<Graphic>, Table<Vector>, */ Table<Raster<CPU>>, Table<Color> /* , Table<GradientStops> */)] value: T,
|
||||
) -> String {
|
||||
serde_json::to_string(&value).unwrap_or_else(|_| "Serialization Error".to_string())
|
||||
}
|
||||
|
||||
/// Joins two strings together.
|
||||
#[node_macro::node(category("Text"))]
|
||||
fn string_concatenate(_: impl Ctx, #[implementations(String)] first: String, second: TextArea) -> String {
|
||||
first.clone() + &second
|
||||
}
|
||||
|
||||
/// Replaces all occurrences of "From" with "To" in the input string.
|
||||
#[node_macro::node(category("Text"))]
|
||||
fn string_replace(_: impl Ctx, string: String, from: TextArea, to: TextArea) -> String {
|
||||
string.replace(&from, &to)
|
||||
}
|
||||
|
||||
/// Extracts a substring from the input string, starting at "Start" and ending before "End".
|
||||
/// Negative indices count from the end of the string.
|
||||
/// If "Start" equals or exceeds "End", the result is an empty string.
|
||||
#[node_macro::node(category("Text"))]
|
||||
fn string_slice(_: impl Ctx, string: String, start: f64, end: f64) -> String {
|
||||
let total_chars = string.chars().count();
|
||||
|
||||
let start = if start < 0. {
|
||||
total_chars.saturating_sub(start.abs() as usize)
|
||||
} else {
|
||||
(start as usize).min(total_chars)
|
||||
};
|
||||
let end = if end <= 0. {
|
||||
total_chars.saturating_sub(end.abs() as usize)
|
||||
} else {
|
||||
(end as usize).min(total_chars)
|
||||
};
|
||||
|
||||
if start >= end {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
string.chars().skip(start).take(end - start).collect()
|
||||
}
|
||||
|
||||
// TODO: Return u32, u64, or usize instead of f64 after #1621 is resolved and has allowed us to implement automatic type conversion in the node graph for nodes with generic type inputs.
|
||||
// TODO: (Currently automatic type conversion only works for concrete types, via the Graphene preprocessor and not the full Graphene type system.)
|
||||
/// Counts the number of characters in a string.
|
||||
#[node_macro::node(category("Text"))]
|
||||
fn string_length(_: impl Ctx, string: String) -> f64 {
|
||||
string.chars().count() as f64
|
||||
}
|
||||
|
||||
/// Splits a string into a list of substrings based on the specified delimeter.
|
||||
/// For example, the delimeter "," will split "a,b,c" into the strings "a", "b", and "c".
|
||||
#[node_macro::node(category("Text"))]
|
||||
fn string_split(
|
||||
_: impl Ctx,
|
||||
/// The string to split into substrings.
|
||||
string: String,
|
||||
/// The character(s) that separate the substrings. These are not included in the outputs.
|
||||
#[default("\\n")]
|
||||
delimeter: String,
|
||||
/// Whether to convert escape sequences found in the delimeter into their corresponding characters:
|
||||
/// "\n" (newline), "\r" (carriage return), "\t" (tab), "\0" (null), and "\\" (backslash)
|
||||
#[default(true)]
|
||||
delimeter_escaping: bool,
|
||||
) -> Vec<String> {
|
||||
let delimeter = if delimeter_escaping {
|
||||
delimeter.replace("\\n", "\n").replace("\\r", "\r").replace("\\t", "\t").replace("\\0", "\0").replace("\\\\", "\\")
|
||||
} else {
|
||||
delimeter
|
||||
};
|
||||
|
||||
string.split(&delimeter).map(str::to_string).collect()
|
||||
}
|
||||
|
||||
/// Evaluates either the "If True" or "If False" input branch based on whether the input condition is true or false.
|
||||
#[node_macro::node(category("Math: Logic"))]
|
||||
async fn switch<T, C: Send + 'n + Clone>(
|
||||
#[implementations(Context)] ctx: C,
|
||||
condition: bool,
|
||||
#[expose]
|
||||
#[implementations(
|
||||
Context -> String,
|
||||
Context -> bool,
|
||||
Context -> f32,
|
||||
Context -> f64,
|
||||
Context -> u32,
|
||||
Context -> u64,
|
||||
Context -> DVec2,
|
||||
Context -> DAffine2,
|
||||
Context -> Table<Artboard>,
|
||||
Context -> Table<Graphic>,
|
||||
Context -> Table<Vector>,
|
||||
Context -> Table<Raster<CPU>>,
|
||||
Context -> Table<Raster<GPU>>,
|
||||
Context -> Table<Color>,
|
||||
Context -> GradientStops,
|
||||
)]
|
||||
if_true: impl Node<C, Output = T>,
|
||||
#[expose]
|
||||
#[implementations(
|
||||
Context -> String,
|
||||
Context -> bool,
|
||||
Context -> f32,
|
||||
Context -> f64,
|
||||
Context -> u32,
|
||||
Context -> u64,
|
||||
Context -> DVec2,
|
||||
Context -> DAffine2,
|
||||
Context -> Table<Artboard>,
|
||||
Context -> Table<Graphic>,
|
||||
Context -> Table<Vector>,
|
||||
Context -> Table<Raster<CPU>>,
|
||||
Context -> Table<Raster<GPU>>,
|
||||
Context -> Table<Color>,
|
||||
Context -> GradientStops,
|
||||
)]
|
||||
if_false: impl Node<C, Output = T>,
|
||||
) -> T {
|
||||
if condition { if_true.eval(ctx).await } else { if_false.eval(ctx).await }
|
||||
}
|
||||
107
node-graph/nodes/gcore/src/memo.rs
Normal file
107
node-graph/nodes/gcore/src/memo.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
use core_types::memo::*;
|
||||
use core_types::{Node, WasmNotSend};
|
||||
use dyn_any::DynFuture;
|
||||
use std::future::Future;
|
||||
use std::hash::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Caches the output of a given node called with a specific input.
|
||||
///
|
||||
/// A cache miss occurs when the Option is None. In this case, the node evaluates the inner node and memoizes (stores) the result.
|
||||
///
|
||||
/// A cache hit occurs when the Option is Some and has a stored hash matching the hash of the call argument. In this case, the node returns the cached value without re-evaluating the inner node.
|
||||
///
|
||||
/// Currently, only one input-output pair is cached. Subsequent calls with different inputs will overwrite the previous cache.
|
||||
#[derive(Default)]
|
||||
pub struct MemoNode<T, CachedNode> {
|
||||
cache: Arc<Mutex<Option<(u64, T)>>>,
|
||||
node: CachedNode,
|
||||
}
|
||||
impl<'i, I: Hash + 'i, T: 'i + Clone + WasmNotSend, CachedNode: 'i> Node<'i, I> for MemoNode<T, CachedNode>
|
||||
where
|
||||
CachedNode: for<'any_input> Node<'any_input, I>,
|
||||
for<'a> <CachedNode as Node<'a, I>>::Output: Future<Output = T> + WasmNotSend,
|
||||
{
|
||||
// TODO: This should return a reference to the cached cached_value
|
||||
// but that requires a lot of lifetime magic <- This was suggested by copilot but is pretty accurate xD
|
||||
type Output = DynFuture<'i, T>;
|
||||
fn eval(&'i self, input: I) -> Self::Output {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
input.hash(&mut hasher);
|
||||
let hash = hasher.finish();
|
||||
|
||||
if let Some(data) = self.cache.lock().as_ref().unwrap().as_ref().and_then(|data| (data.0 == hash).then_some(data.1.clone())) {
|
||||
Box::pin(async move { data })
|
||||
} else {
|
||||
let fut = self.node.eval(input);
|
||||
let cache = self.cache.clone();
|
||||
Box::pin(async move {
|
||||
let value = fut.await;
|
||||
*cache.lock().unwrap() = Some((hash, value.clone()));
|
||||
value
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&self) {
|
||||
self.cache.lock().unwrap().take();
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, CachedNode> MemoNode<T, CachedNode> {
|
||||
pub fn new(node: CachedNode) -> MemoNode<T, CachedNode> {
|
||||
MemoNode { cache: Default::default(), node }
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::module_inception)]
|
||||
pub mod memo {
|
||||
use core_types::ProtoNodeIdentifier;
|
||||
|
||||
pub const IDENTIFIER: ProtoNodeIdentifier = ProtoNodeIdentifier::new("graphene_core::memo::MemoNode");
|
||||
}
|
||||
|
||||
/// Caches the output of the last graph evaluation for introspection
|
||||
#[derive(Default)]
|
||||
pub struct MonitorNode<I, T, N> {
|
||||
#[allow(clippy::type_complexity)]
|
||||
io: Arc<Mutex<Option<Arc<IORecord<I, T>>>>>,
|
||||
node: N,
|
||||
}
|
||||
|
||||
impl<'i, T, I, N> Node<'i, I> for MonitorNode<I, T, N>
|
||||
where
|
||||
I: Clone + 'static + Send + Sync,
|
||||
T: Clone + 'static + Send + Sync,
|
||||
for<'a> N: Node<'a, I, Output: Future<Output = T> + WasmNotSend> + 'i,
|
||||
{
|
||||
type Output = DynFuture<'i, T>;
|
||||
fn eval(&'i self, input: I) -> Self::Output {
|
||||
let io = self.io.clone();
|
||||
let output_fut = self.node.eval(input.clone());
|
||||
Box::pin(async move {
|
||||
let output = output_fut.await;
|
||||
*io.lock().unwrap() = Some(Arc::new(IORecord { input, output: output.clone() }));
|
||||
output
|
||||
})
|
||||
}
|
||||
|
||||
fn serialize(&self) -> Option<Arc<dyn std::any::Any + Send + Sync>> {
|
||||
let io = self.io.lock().unwrap();
|
||||
(io).as_ref().map(|output| output.clone() as Arc<dyn std::any::Any + Send + Sync>)
|
||||
}
|
||||
}
|
||||
|
||||
impl<I, T, N> MonitorNode<I, T, N> {
|
||||
pub fn new(node: N) -> MonitorNode<I, T, N> {
|
||||
MonitorNode { io: Arc::new(Mutex::new(None)), node }
|
||||
}
|
||||
}
|
||||
|
||||
pub mod monitor {
|
||||
use core_types::ProtoNodeIdentifier;
|
||||
|
||||
pub const IDENTIFIER: ProtoNodeIdentifier = ProtoNodeIdentifier::new("graphene_core::memo::MonitorNode");
|
||||
}
|
||||
33
node-graph/nodes/gcore/src/ops.rs
Normal file
33
node-graph/nodes/gcore/src/ops.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use core_types::{Ctx, ExtractFootprint, ops::Convert, transform::Footprint};
|
||||
use std::marker::PhantomData;
|
||||
|
||||
// Re-export TypeNode from core-types for convenience
|
||||
pub use core_types::ops::TypeNode;
|
||||
|
||||
// TODO: Rename to "Passthrough"
|
||||
/// Passes-through the input value without changing it.
|
||||
/// This is useful for rerouting wires for organization purposes.
|
||||
#[node_macro::node(skip_impl)]
|
||||
fn identity<'i, T: 'i + Send>(value: T) -> T {
|
||||
value
|
||||
}
|
||||
|
||||
#[node_macro::node(skip_impl)]
|
||||
fn into<'i, T: 'i + Send + Into<O>, O: 'i + Send>(_: impl Ctx, value: T, _out_ty: PhantomData<O>) -> O {
|
||||
value.into()
|
||||
}
|
||||
|
||||
#[node_macro::node(skip_impl)]
|
||||
async fn convert<'i, T: 'i + Send + Convert<O, C>, O: 'i + Send, C: 'i + Send>(ctx: impl Ctx + ExtractFootprint, value: T, converter: C, _out_ty: PhantomData<O>) -> O {
|
||||
value.convert(*ctx.try_footprint().unwrap_or(&Footprint::DEFAULT), converter).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
pub fn identity_node() {
|
||||
assert_eq!(identity(&4), &4);
|
||||
}
|
||||
}
|
||||
18
node-graph/nodes/graphic/Cargo.toml
Normal file
18
node-graph/nodes/graphic/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "graphic-nodes"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[dependencies]
|
||||
# Local dependencies
|
||||
core-types = { workspace = true }
|
||||
graphic-types = { workspace = true }
|
||||
vector-types = { workspace = true }
|
||||
raster-types = { workspace = true }
|
||||
|
||||
# Workspace dependencies
|
||||
dyn-any = { workspace = true }
|
||||
glam = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
node-macro = { workspace = true }
|
||||
63
node-graph/nodes/graphic/src/artboard.rs
Normal file
63
node-graph/nodes/graphic/src/artboard.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use core_types::{CloneVarArgs, Color, Context, Ctx, ExtractAll, OwnedContextImpl, table::Table, transform::TransformMut};
|
||||
use glam::{DAffine2, DVec2, IVec2};
|
||||
use graphic_types::{
|
||||
Artboard, Vector,
|
||||
graphic::{Graphic, IntoGraphicTable},
|
||||
};
|
||||
use raster_types::{CPU, GPU, Raster};
|
||||
use vector_types::GradientStops;
|
||||
|
||||
/// Constructs a new single artboard table with the chosen properties.
|
||||
#[node_macro::node(category(""))]
|
||||
pub async fn create_artboard<T: IntoGraphicTable + 'n>(
|
||||
ctx: impl ExtractAll + CloneVarArgs + Ctx,
|
||||
/// Graphics to include within the artboard.
|
||||
#[implementations(
|
||||
Context -> Table<Graphic>,
|
||||
Context -> Table<Vector>,
|
||||
Context -> Table<Raster<CPU>>,
|
||||
Context -> Table<Raster<GPU>>,
|
||||
Context -> Table<Color>,
|
||||
Context -> Table<GradientStops>,
|
||||
Context -> DAffine2,
|
||||
)]
|
||||
content: impl Node<Context<'static>, Output = T>,
|
||||
/// Name of the artboard, shown in parts of the editor.
|
||||
label: String,
|
||||
/// Coordinate of the top-left corner of the artboard within the document.
|
||||
location: DVec2,
|
||||
/// Width and height of the artboard within the document. Only integers are valid.
|
||||
dimensions: DVec2,
|
||||
/// Color of the artboard background. Only positive integers are valid.
|
||||
background: Table<Color>,
|
||||
/// Whether to cut off the contained content that extends outside the artboard, or keep it visible.
|
||||
clip: bool,
|
||||
) -> Table<Artboard> {
|
||||
let location = location.as_ivec2();
|
||||
|
||||
let footprint = ctx.try_footprint().copied();
|
||||
let mut new_ctx = OwnedContextImpl::from(ctx);
|
||||
if let Some(mut footprint) = footprint {
|
||||
footprint.translate(location.as_dvec2());
|
||||
new_ctx = new_ctx.with_footprint(footprint);
|
||||
}
|
||||
let content = content.eval(new_ctx.into_context()).await.into_graphic_table();
|
||||
|
||||
let dimensions = dimensions.as_ivec2().max(IVec2::ONE);
|
||||
|
||||
let location = location.min(location + dimensions);
|
||||
|
||||
let dimensions = dimensions.abs();
|
||||
|
||||
let background: Option<Color> = background.into();
|
||||
let background = background.unwrap_or(Color::WHITE);
|
||||
|
||||
Table::new_from_element(Artboard {
|
||||
content,
|
||||
label,
|
||||
location,
|
||||
dimensions,
|
||||
background,
|
||||
clip,
|
||||
})
|
||||
}
|
||||
239
node-graph/nodes/graphic/src/graphic.rs
Normal file
239
node-graph/nodes/graphic/src/graphic.rs
Normal file
@@ -0,0 +1,239 @@
|
||||
use core_types::Color;
|
||||
use core_types::{
|
||||
Ctx,
|
||||
blending::AlphaBlending,
|
||||
table::{Table, TableRow},
|
||||
uuid::NodeId,
|
||||
};
|
||||
use glam::{DAffine2, DVec2};
|
||||
use graphic_types::{
|
||||
Artboard, Vector,
|
||||
graphic::{Graphic, IntoGraphicTable},
|
||||
};
|
||||
use raster_types::{CPU, GPU, Raster};
|
||||
use vector_types::GradientStops;
|
||||
|
||||
/// Performs internal editor record-keeping that enables tools to target this network's layer.
|
||||
/// This node associates the ID of the network's parent layer to every element of output data.
|
||||
/// This technical detail may be ignored by users, and will be phased out in the future.
|
||||
#[node_macro::node(category(""))]
|
||||
pub async fn source_node_id<I: 'n + Send + Clone>(
|
||||
_: impl Ctx,
|
||||
#[implementations(
|
||||
Table<Artboard>,
|
||||
Table<Graphic>,
|
||||
Table<Vector>,
|
||||
Table<Raster<CPU>>,
|
||||
Table<Raster<GPU>>,
|
||||
Table<Color>,
|
||||
Table<GradientStops>,
|
||||
)]
|
||||
content: Table<I>,
|
||||
node_path: Vec<NodeId>,
|
||||
) -> Table<I> {
|
||||
// Get the penultimate element of the node path, or None if the path is too short
|
||||
// This is used to get the ID of the user-facing parent layer node (whose network contains this internal node).
|
||||
let source_node_id = node_path.get(node_path.len().wrapping_sub(2)).copied();
|
||||
|
||||
let mut content = content;
|
||||
for row in content.iter_mut() {
|
||||
*row.source_node_id = source_node_id;
|
||||
}
|
||||
|
||||
content
|
||||
}
|
||||
|
||||
/// Joins two tables of the same type, extending the base table with the rows of the new table.
|
||||
#[node_macro::node(category("General"))]
|
||||
pub async fn extend<I: 'n + Send + Clone>(
|
||||
_: impl Ctx,
|
||||
/// The table whose rows will appear at the start of the extended table.
|
||||
#[implementations(Table<Artboard>, Table<Graphic>, Table<Vector>, Table<Raster<CPU>>, Table<Raster<GPU>>, Table<Color>, Table<GradientStops>)]
|
||||
base: Table<I>,
|
||||
/// The table whose rows will appear at the end of the extended table.
|
||||
#[expose]
|
||||
#[implementations(Table<Artboard>, Table<Graphic>, Table<Vector>, Table<Raster<CPU>>, Table<Raster<GPU>>, Table<Color>, Table<GradientStops>)]
|
||||
new: Table<I>,
|
||||
) -> Table<I> {
|
||||
let mut base = base;
|
||||
base.extend(new);
|
||||
|
||||
base
|
||||
}
|
||||
|
||||
// TODO: Eventually remove this document upgrade code
|
||||
/// Performs an obsolete function as part of a migration from an older document format.
|
||||
/// Users are advised to delete this node and replace it with a new one.
|
||||
#[node_macro::node(category(""))]
|
||||
pub async fn legacy_layer_extend<I: 'n + Send + Clone>(
|
||||
_: impl Ctx,
|
||||
#[implementations(Table<Artboard>, Table<Graphic>, Table<Vector>, Table<Raster<CPU>>, Table<Raster<GPU>>, Table<Color>, Table<GradientStops>)] base: Table<I>,
|
||||
#[expose]
|
||||
#[implementations(Table<Artboard>, Table<Graphic>, Table<Vector>, Table<Raster<CPU>>, Table<Raster<GPU>>, Table<Color>, Table<GradientStops>)]
|
||||
new: Table<I>,
|
||||
nested_node_path: Vec<NodeId>,
|
||||
) -> Table<I> {
|
||||
// Get the penultimate element of the node path, or None if the path is too short
|
||||
// This is used to get the ID of the user-facing parent layer-style node (which encapsulates this internal node).
|
||||
let source_node_id = nested_node_path.get(nested_node_path.len().wrapping_sub(2)).copied();
|
||||
|
||||
let mut base = base;
|
||||
for row in new.into_iter() {
|
||||
base.push(TableRow { source_node_id, ..row });
|
||||
}
|
||||
|
||||
base
|
||||
}
|
||||
|
||||
/// Nests the input graphical content in a wrapper graphic. This essentially "groups" the input.
|
||||
/// The inverse of this node is 'Flatten Graphic'.
|
||||
#[node_macro::node(category("General"))]
|
||||
pub async fn wrap_graphic<T: Into<Graphic> + 'n>(
|
||||
_: impl Ctx,
|
||||
#[implementations(
|
||||
Table<Graphic>,
|
||||
Table<Vector>,
|
||||
Table<Raster<CPU>>,
|
||||
Table<Raster<GPU>>,
|
||||
Table<Color>,
|
||||
Table<GradientStops>,
|
||||
DAffine2,
|
||||
)]
|
||||
content: T,
|
||||
) -> Table<Graphic> {
|
||||
Table::new_from_element(content.into())
|
||||
}
|
||||
|
||||
/// Converts a table of graphical content into a graphic table by placing it into an element of a new wrapper graphic table.
|
||||
/// If it is already a graphic table, it is not wrapped again. Use the 'Wrap Graphic' node if wrapping is always desired.
|
||||
#[node_macro::node(category("General"))]
|
||||
pub async fn to_graphic<T: IntoGraphicTable + 'n>(
|
||||
_: impl Ctx,
|
||||
#[implementations(
|
||||
Table<Graphic>,
|
||||
Table<Vector>,
|
||||
Table<Raster<CPU>>,
|
||||
Table<Raster<GPU>>,
|
||||
Table<Color>,
|
||||
Table<GradientStops>,
|
||||
)]
|
||||
content: T,
|
||||
) -> Table<Graphic> {
|
||||
content.into_graphic_table()
|
||||
}
|
||||
|
||||
/// Removes a level of nesting from a graphic table, or all nesting if "Fully Flatten" is enabled.
|
||||
#[node_macro::node(category("General"))]
|
||||
pub async fn flatten_graphic(_: impl Ctx, content: Table<Graphic>, fully_flatten: bool) -> Table<Graphic> {
|
||||
// TODO: Avoid mutable reference, instead return a new Table<Graphic>?
|
||||
fn flatten_table(output_graphic_table: &mut Table<Graphic>, current_graphic_table: Table<Graphic>, fully_flatten: bool, recursion_depth: usize) {
|
||||
for current_row in current_graphic_table.iter() {
|
||||
let current_element = current_row.element.clone();
|
||||
let reference = *current_row.source_node_id;
|
||||
|
||||
let recurse = fully_flatten || recursion_depth == 0;
|
||||
|
||||
match current_element {
|
||||
// If we're allowed to recurse, flatten any graphics we encounter
|
||||
Graphic::Graphic(mut current_element) if recurse => {
|
||||
// Apply the parent graphic's transform to all child elements
|
||||
for graphic in current_element.iter_mut() {
|
||||
*graphic.transform = *current_row.transform * *graphic.transform;
|
||||
}
|
||||
|
||||
flatten_table(output_graphic_table, current_element, fully_flatten, recursion_depth + 1);
|
||||
}
|
||||
// Push any leaf Graphic elements we encounter, which can be either Graphic table elements beyond the recursion depth, or table elements other than Graphic tables
|
||||
_ => {
|
||||
output_graphic_table.push(TableRow {
|
||||
element: current_element,
|
||||
transform: *current_row.transform,
|
||||
alpha_blending: *current_row.alpha_blending,
|
||||
source_node_id: reference,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut output = Table::new();
|
||||
flatten_table(&mut output, content, fully_flatten, 0);
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
/// Converts a graphic table into a vector table by deeply flattening any vector content it contains, and discarding any non-vector content.
|
||||
#[node_macro::node(category("Vector"))]
|
||||
pub async fn flatten_vector(_: impl Ctx, content: Table<Graphic>) -> Table<Vector> {
|
||||
// TODO: Avoid mutable reference, instead return a new Table<Graphic>?
|
||||
fn flatten_table(output_vector_table: &mut Table<Vector>, current_graphic_table: Table<Graphic>) {
|
||||
for current_graphic_row in current_graphic_table.iter() {
|
||||
let current_graphic = current_graphic_row.element.clone();
|
||||
let source_node_id = *current_graphic_row.source_node_id;
|
||||
|
||||
match current_graphic {
|
||||
// If we're allowed to recurse, flatten any tables we encounter
|
||||
Graphic::Graphic(mut current_graphic_table) => {
|
||||
// Apply the parent graphic's transform to all child elements
|
||||
for graphic in current_graphic_table.iter_mut() {
|
||||
*graphic.transform = *current_graphic_row.transform * *graphic.transform;
|
||||
}
|
||||
|
||||
flatten_table(output_vector_table, current_graphic_table);
|
||||
}
|
||||
// Push any leaf Vector elements we encounter
|
||||
Graphic::Vector(vector_table) => {
|
||||
for current_vector_row in vector_table.iter() {
|
||||
output_vector_table.push(TableRow {
|
||||
element: current_vector_row.element.clone(),
|
||||
transform: *current_graphic_row.transform * *current_vector_row.transform,
|
||||
alpha_blending: AlphaBlending {
|
||||
blend_mode: current_vector_row.alpha_blending.blend_mode,
|
||||
opacity: current_graphic_row.alpha_blending.opacity * current_vector_row.alpha_blending.opacity,
|
||||
fill: current_vector_row.alpha_blending.fill,
|
||||
clip: current_vector_row.alpha_blending.clip,
|
||||
},
|
||||
source_node_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut output = Table::new();
|
||||
flatten_table(&mut output, content);
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
/// Returns the value at the specified index in the collection.
|
||||
/// If no value exists at that index, the type's default value is returned.
|
||||
#[node_macro::node(category("General"))]
|
||||
pub fn index_elements<T: graphic_types::graphic::AtIndex + Clone + Default>(
|
||||
_: impl Ctx,
|
||||
/// The collection of data, such as a list or table.
|
||||
#[implementations(
|
||||
Vec<f64>,
|
||||
Vec<u32>,
|
||||
Vec<u64>,
|
||||
Vec<DVec2>,
|
||||
Vec<String>,
|
||||
Table<Artboard>,
|
||||
Table<Graphic>,
|
||||
Table<Vector>,
|
||||
Table<Raster<CPU>>,
|
||||
Table<Raster<GPU>>,
|
||||
Table<Color>,
|
||||
Table<GradientStops>,
|
||||
)]
|
||||
collection: T,
|
||||
/// The index of the item to retrieve, starting from 0 for the first item.
|
||||
index: u32,
|
||||
) -> T::Output
|
||||
where
|
||||
T::Output: Clone + Default,
|
||||
{
|
||||
collection.at_index(index as usize).unwrap_or_default()
|
||||
}
|
||||
6
node-graph/nodes/graphic/src/lib.rs
Normal file
6
node-graph/nodes/graphic/src/lib.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod artboard;
|
||||
pub mod graphic;
|
||||
|
||||
// Re-export all nodes
|
||||
pub use artboard::*;
|
||||
pub use graphic::*;
|
||||
73
node-graph/nodes/gstd/Cargo.toml
Normal file
73
node-graph/nodes/gstd/Cargo.toml
Normal file
@@ -0,0 +1,73 @@
|
||||
[package]
|
||||
name = "graphene-std"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Graphene standard library"
|
||||
authors = ["Graphite Authors <contact@graphite.rs>"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[features]
|
||||
default = ["wasm", "wgpu", "shader-nodes"]
|
||||
gpu = []
|
||||
wgpu = ["gpu", "graph-craft/wgpu", "graphene-application-io/wgpu"]
|
||||
wasm = [
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"graphene-application-io/wasm",
|
||||
"image/png",
|
||||
]
|
||||
image-compare = []
|
||||
vello = ["gpu"]
|
||||
resvg = []
|
||||
shader-nodes = ["raster-nodes/shader-nodes"]
|
||||
|
||||
[dependencies]
|
||||
# Local dependencies
|
||||
dyn-any = { workspace = true }
|
||||
graph-craft = { workspace = true }
|
||||
wgpu-executor = { workspace = true }
|
||||
core-types = { workspace = true }
|
||||
vector-types = { workspace = true }
|
||||
graphic-types = { workspace = true }
|
||||
blending-nodes = { workspace = true }
|
||||
text-nodes = { workspace = true }
|
||||
transform-nodes = { workspace = true }
|
||||
vector-nodes = { workspace = true }
|
||||
path-bool-nodes = { workspace = true }
|
||||
math-nodes = { workspace = true }
|
||||
rendering = { workspace = true }
|
||||
graphene-application-io = { workspace = true }
|
||||
raster-nodes = { workspace = true }
|
||||
brush-nodes = { workspace = true }
|
||||
graphene-core = { workspace = true }
|
||||
graphic-nodes = { workspace = true }
|
||||
|
||||
# Workspace dependencies
|
||||
log = { workspace = true }
|
||||
glam = { workspace = true }
|
||||
node-macro = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
image = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
|
||||
# Optional workspace dependencies
|
||||
wasm-bindgen = { workspace = true, optional = true }
|
||||
wasm-bindgen-futures = { workspace = true, optional = true }
|
||||
tokio = { workspace = true, optional = true }
|
||||
vello = { workspace = true }
|
||||
vello_encoding = { workspace = true }
|
||||
web-sys = { workspace = true, optional = true, features = [
|
||||
"Window",
|
||||
"CanvasRenderingContext2d",
|
||||
"ImageData",
|
||||
"Document",
|
||||
"Navigator",
|
||||
"Gpu",
|
||||
"HtmlCanvasElement",
|
||||
"HtmlImageElement",
|
||||
"ImageBitmapRenderingContext",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
27
node-graph/nodes/gstd/src/any.rs
Normal file
27
node-graph/nodes/gstd/src/any.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use core_types::NodeIO;
|
||||
use core_types::WasmNotSend;
|
||||
pub use core_types::registry::{DowncastBothNode, DynAnyNode, FutureWrapperNode, PanicNode};
|
||||
pub use core_types::{Node, generic, ops};
|
||||
use dyn_any::StaticType;
|
||||
pub use graph_craft::proto::{Any, NodeContainer, TypeErasedBox, TypeErasedNode};
|
||||
use graph_craft::proto::{FutureAny, SharedNodeContainer};
|
||||
|
||||
pub trait IntoTypeErasedNode<'n> {
|
||||
fn into_type_erased(self) -> TypeErasedBox<'n>;
|
||||
}
|
||||
|
||||
impl<'n, N: 'n> IntoTypeErasedNode<'n> for N
|
||||
where
|
||||
N: for<'i> NodeIO<'i, Any<'i>, Output = FutureAny<'i>> + Sync + WasmNotSend,
|
||||
{
|
||||
fn into_type_erased(self) -> TypeErasedBox<'n> {
|
||||
Box::new(self)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn input_node<O: StaticType>(n: SharedNodeContainer) -> DowncastBothNode<(), O> {
|
||||
downcast_node(n)
|
||||
}
|
||||
pub fn downcast_node<I: StaticType, O: StaticType>(n: SharedNodeContainer) -> DowncastBothNode<I, O> {
|
||||
DowncastBothNode::new(n)
|
||||
}
|
||||
117
node-graph/nodes/gstd/src/lib.rs
Normal file
117
node-graph/nodes/gstd/src/lib.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
pub mod any;
|
||||
pub mod render_node;
|
||||
pub mod text;
|
||||
#[cfg(feature = "wasm")]
|
||||
pub mod wasm_application_io;
|
||||
|
||||
pub use blending_nodes;
|
||||
pub use brush_nodes as brush;
|
||||
pub use core_types::*;
|
||||
pub use graphene_application_io as application_io;
|
||||
pub use graphene_core;
|
||||
pub use graphic_nodes;
|
||||
pub use math_nodes;
|
||||
pub use path_bool_nodes as path_bool;
|
||||
pub use raster_nodes;
|
||||
pub use text_nodes;
|
||||
pub use transform_nodes;
|
||||
pub use vector_nodes;
|
||||
pub use vector_types;
|
||||
|
||||
/// Backward compatibility re-exports
|
||||
pub mod vector {
|
||||
pub use graphic_types::Vector;
|
||||
pub use vector_types::vector::{VectorModification, VectorModificationType, misc, style};
|
||||
pub use vector_types::*;
|
||||
|
||||
// Re-export commonly used types and submodules
|
||||
pub use vector_types::vector::algorithms;
|
||||
pub use vector_types::vector::click_target;
|
||||
pub use vector_types::vector::misc::HandleId;
|
||||
pub use vector_types::vector::{PointId, RegionId, SegmentId, StrokeId};
|
||||
pub use vector_types::vector::{deserialize_hashmap, serialize_hashmap};
|
||||
|
||||
// Re-export HandleExt trait and NoHashBuilder
|
||||
pub use vector_types::vector::HandleExt;
|
||||
pub use vector_types::vector::NoHashBuilder;
|
||||
|
||||
// Re-export vector node modules and functions
|
||||
pub use vector_nodes::*;
|
||||
}
|
||||
|
||||
pub mod graphic {
|
||||
pub use graphic_nodes::graphic::*;
|
||||
pub use graphic_types::Artboard;
|
||||
pub use graphic_types::graphic::*;
|
||||
}
|
||||
|
||||
pub mod artboard {
|
||||
pub use graphic_nodes::artboard::*;
|
||||
pub use graphic_types::artboard::*;
|
||||
}
|
||||
|
||||
pub mod subpath {
|
||||
pub use vector_types::subpath::*;
|
||||
}
|
||||
|
||||
pub mod gradient {
|
||||
pub use vector_types::GradientStops;
|
||||
}
|
||||
|
||||
pub mod transform {
|
||||
pub use core_types::transform::*;
|
||||
pub use vector_types::ReferencePoint;
|
||||
}
|
||||
|
||||
pub mod math {
|
||||
pub use core_types::math::quad;
|
||||
|
||||
pub mod math_ext {
|
||||
pub use vector_types::{QuadExt, RectExt};
|
||||
}
|
||||
}
|
||||
|
||||
pub mod logic {
|
||||
pub use graphene_core::logic::*;
|
||||
}
|
||||
|
||||
pub use graphene_core::debug;
|
||||
|
||||
// Re-export graphene_core modules for backward compatibility
|
||||
pub mod ops {
|
||||
pub use core_types::ops::*;
|
||||
pub use graphene_core::ops::*;
|
||||
}
|
||||
|
||||
pub mod extract_xy {
|
||||
pub use graphene_core::extract_xy::*;
|
||||
}
|
||||
|
||||
pub mod animation {
|
||||
pub use graphene_core::animation::*;
|
||||
}
|
||||
|
||||
// Re-export at top level for convenience
|
||||
pub use graphic_types::{Artboard, Graphic, Vector};
|
||||
|
||||
/// stop gap solutions until all paths have been replaced with their absolute ones
|
||||
pub mod renderer {
|
||||
pub use core_types::math::quad::Quad;
|
||||
pub use core_types::math::rect::Rect;
|
||||
pub use rendering::*;
|
||||
}
|
||||
|
||||
pub mod raster {
|
||||
pub use graphic_types::raster_types::*;
|
||||
pub use raster_nodes::adjustments::*;
|
||||
pub use raster_nodes::*;
|
||||
}
|
||||
|
||||
pub mod raster_types {
|
||||
pub use graphic_types::raster_types::*;
|
||||
}
|
||||
|
||||
pub mod memo {
|
||||
pub use core_types::memo::*;
|
||||
pub use graphene_core::memo::*;
|
||||
}
|
||||
230
node-graph/nodes/gstd/src/render_node.rs
Normal file
230
node-graph/nodes/gstd/src/render_node.rs
Normal file
@@ -0,0 +1,230 @@
|
||||
use core_types::table::Table;
|
||||
use core_types::transform::Footprint;
|
||||
use core_types::{CloneVarArgs, ExtractAll, ExtractVarArgs};
|
||||
use core_types::{Color, Context, Ctx, ExtractFootprint, OwnedContextImpl, WasmNotSend};
|
||||
use graph_craft::document::value::RenderOutput;
|
||||
pub use graph_craft::document::value::RenderOutputType;
|
||||
pub use graph_craft::wasm_application_io::*;
|
||||
use graphene_application_io::{ApplicationIo, ExportFormat, ImageTexture, RenderConfig, SurfaceFrame};
|
||||
use graphic_types::Artboard;
|
||||
use graphic_types::Graphic;
|
||||
use graphic_types::Vector;
|
||||
use graphic_types::raster_types::Image;
|
||||
use graphic_types::raster_types::{CPU, Raster};
|
||||
use rendering::{Render, RenderOutputType as RenderOutputTypeRequest, RenderParams, RenderSvgSegmentList, SvgRender, format_transform_matrix};
|
||||
use rendering::{RenderMetadata, SvgSegment};
|
||||
use std::sync::Arc;
|
||||
use vector_types::GradientStops;
|
||||
use wgpu_executor::RenderContext;
|
||||
|
||||
/// List of (canvas id, image data) pairs for embedding images as canvases in the final SVG string.
|
||||
type ImageData = Vec<(u64, Image<Color>)>;
|
||||
|
||||
#[derive(Clone, dyn_any::DynAny)]
|
||||
pub enum RenderIntermediateType {
|
||||
Vello(Arc<(vello::Scene, RenderContext)>),
|
||||
Svg(Arc<(String, ImageData, String)>),
|
||||
}
|
||||
#[derive(Clone, dyn_any::DynAny)]
|
||||
pub struct RenderIntermediate {
|
||||
ty: RenderIntermediateType,
|
||||
metadata: RenderMetadata,
|
||||
contains_artboard: bool,
|
||||
}
|
||||
|
||||
#[node_macro::node(category(""))]
|
||||
async fn render_intermediate<'a: 'n, T: 'static + Render + WasmNotSend + Send + Sync>(
|
||||
ctx: impl Ctx + ExtractVarArgs + ExtractAll + CloneVarArgs,
|
||||
#[implementations(
|
||||
Context -> Table<Artboard>,
|
||||
Context -> Table<Graphic>,
|
||||
Context -> Table<Vector>,
|
||||
Context -> Table<Raster<CPU>>,
|
||||
Context -> Table<Color>,
|
||||
Context -> Table<GradientStops>,
|
||||
)]
|
||||
data: impl Node<Context<'static>, Output = T>,
|
||||
) -> RenderIntermediate {
|
||||
let render_params = ctx
|
||||
.vararg(0)
|
||||
.expect("Did not find var args")
|
||||
.downcast_ref::<RenderParams>()
|
||||
.expect("Downcasting render params yielded invalid type");
|
||||
|
||||
let ctx = OwnedContextImpl::from(ctx.clone()).into_context();
|
||||
let data = data.eval(ctx).await;
|
||||
|
||||
let footprint = Footprint::default();
|
||||
let mut metadata = RenderMetadata::default();
|
||||
data.collect_metadata(&mut metadata, footprint, None);
|
||||
let contains_artboard = data.contains_artboard();
|
||||
|
||||
match &render_params.render_output_type {
|
||||
RenderOutputTypeRequest::Vello => {
|
||||
let mut scene = vello::Scene::new();
|
||||
|
||||
let mut context = wgpu_executor::RenderContext::default();
|
||||
data.render_to_vello(&mut scene, Default::default(), &mut context, render_params);
|
||||
|
||||
RenderIntermediate {
|
||||
ty: RenderIntermediateType::Vello(Arc::new((scene, context))),
|
||||
metadata,
|
||||
contains_artboard,
|
||||
}
|
||||
}
|
||||
RenderOutputTypeRequest::Svg => {
|
||||
let mut render = SvgRender::new();
|
||||
|
||||
data.render_svg(&mut render, render_params);
|
||||
|
||||
RenderIntermediate {
|
||||
ty: RenderIntermediateType::Svg(Arc::new((render.svg.to_svg_string(), render.image_data, render.svg_defs.clone()))),
|
||||
metadata,
|
||||
contains_artboard,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[node_macro::node(category(""))]
|
||||
async fn create_context<'a: 'n>(
|
||||
// Context injections are defined in the wrap_network_in_scope function
|
||||
render_config: RenderConfig,
|
||||
data: impl Node<Context<'static>, Output = RenderOutput>,
|
||||
) -> RenderOutput {
|
||||
let footprint = render_config.viewport;
|
||||
|
||||
let render_output_type = match render_config.export_format {
|
||||
ExportFormat::Svg => RenderOutputTypeRequest::Svg,
|
||||
ExportFormat::Raster => RenderOutputTypeRequest::Vello,
|
||||
};
|
||||
|
||||
let render_params = RenderParams {
|
||||
render_mode: render_config.render_mode,
|
||||
hide_artboards: render_config.hide_artboards,
|
||||
for_export: render_config.for_export,
|
||||
render_output_type,
|
||||
footprint: Footprint::default(),
|
||||
scale: render_config.scale,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let ctx = OwnedContextImpl::default()
|
||||
.with_footprint(footprint)
|
||||
.with_real_time(render_config.time.time)
|
||||
.with_animation_time(render_config.time.animation_time.as_secs_f64())
|
||||
.with_vararg(Box::new(render_params))
|
||||
.into_context();
|
||||
|
||||
data.eval(ctx).await
|
||||
}
|
||||
|
||||
#[node_macro::node(category(""))]
|
||||
async fn render<'a: 'n>(
|
||||
ctx: impl Ctx + ExtractFootprint + ExtractVarArgs,
|
||||
editor_api: &'a WasmEditorApi,
|
||||
data: RenderIntermediate,
|
||||
_surface_handle: impl Node<Context<'static>, Output = Option<wgpu_executor::WgpuSurface>>,
|
||||
) -> RenderOutput {
|
||||
let footprint = ctx.footprint();
|
||||
let render_params = ctx
|
||||
.vararg(0)
|
||||
.expect("Did not find var args")
|
||||
.downcast_ref::<RenderParams>()
|
||||
.expect("Downcasting render params yielded invalid type");
|
||||
let mut render_params = render_params.clone();
|
||||
render_params.footprint = *footprint;
|
||||
let render_params = &render_params;
|
||||
|
||||
let RenderIntermediate { ty, mut metadata, contains_artboard } = data;
|
||||
metadata.apply_transform(footprint.transform);
|
||||
|
||||
let data = match (render_params.render_output_type, &ty) {
|
||||
(RenderOutputTypeRequest::Svg, RenderIntermediateType::Svg(svg_data)) => {
|
||||
let mut rendering = SvgRender::new();
|
||||
if !contains_artboard && !render_params.hide_artboards {
|
||||
rendering.leaf_tag("rect", |attributes| {
|
||||
attributes.push("x", "0");
|
||||
attributes.push("y", "0");
|
||||
attributes.push("width", footprint.resolution.x.to_string());
|
||||
attributes.push("height", footprint.resolution.y.to_string());
|
||||
let matrix = format_transform_matrix(footprint.transform.inverse());
|
||||
if !matrix.is_empty() {
|
||||
attributes.push("transform", matrix);
|
||||
}
|
||||
attributes.push("fill", "white");
|
||||
});
|
||||
}
|
||||
rendering.svg.push(SvgSegment::from(svg_data.0.clone()));
|
||||
rendering.image_data = svg_data.1.clone();
|
||||
rendering.svg_defs = svg_data.2.clone();
|
||||
|
||||
rendering.wrap_with_transform(footprint.transform, Some(footprint.resolution.as_dvec2()));
|
||||
RenderOutputType::Svg {
|
||||
svg: rendering.svg.to_svg_string(),
|
||||
image_data: rendering.image_data,
|
||||
}
|
||||
}
|
||||
(RenderOutputTypeRequest::Vello, RenderIntermediateType::Vello(vello_data)) => {
|
||||
let Some(exec) = editor_api.application_io.as_ref().unwrap().gpu_executor() else {
|
||||
unreachable!("Attempted to render with Vello when no GPU executor is available");
|
||||
};
|
||||
let (child, context) = Arc::as_ref(vello_data);
|
||||
|
||||
let surface_handle = if cfg!(all(feature = "vello", target_family = "wasm")) {
|
||||
_surface_handle.eval(None).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// When rendering to a surface, we do not want to apply the scale
|
||||
let scale = if surface_handle.is_none() { render_params.scale } else { 1. };
|
||||
|
||||
let scale_transform = glam::DAffine2::from_scale(glam::DVec2::splat(scale));
|
||||
let footprint_transform = scale_transform * footprint.transform;
|
||||
let footprint_transform_vello = vello::kurbo::Affine::new(footprint_transform.to_cols_array());
|
||||
|
||||
let mut scene = vello::Scene::new();
|
||||
scene.append(child, Some(footprint_transform_vello));
|
||||
|
||||
let resolution = (footprint.resolution.as_dvec2() * scale).as_uvec2();
|
||||
|
||||
// We now replace all transforms which are supposed to be infinite with a transform which covers the entire viewport
|
||||
// See <https://xi.zulipchat.com/#narrow/channel/197075-vello/topic/Full.20screen.20color.2Fgradients/near/538435044> for more detail
|
||||
let scaled_infinite_transform = vello::kurbo::Affine::scale_non_uniform(resolution.x as f64, resolution.y as f64);
|
||||
let encoding = scene.encoding_mut();
|
||||
for transform in encoding.transforms.iter_mut() {
|
||||
if transform.matrix[0] == f32::INFINITY {
|
||||
*transform = vello_encoding::Transform::from_kurbo(&scaled_infinite_transform);
|
||||
}
|
||||
}
|
||||
|
||||
let mut background = Color::from_rgb8_srgb(0x22, 0x22, 0x22);
|
||||
if !contains_artboard && !render_params.hide_artboards {
|
||||
background = Color::WHITE;
|
||||
}
|
||||
|
||||
if let Some(surface_handle) = surface_handle {
|
||||
exec.render_vello_scene(&scene, &surface_handle, resolution, context, background)
|
||||
.await
|
||||
.expect("Failed to render Vello scene");
|
||||
|
||||
let frame = SurfaceFrame {
|
||||
surface_id: surface_handle.window_id,
|
||||
// TODO: Find a cleaner way to get the unscaled resolution here.
|
||||
// This is done because the surface frame (canvas) is in logical pixels, not physical pixels.
|
||||
resolution,
|
||||
transform: glam::DAffine2::IDENTITY,
|
||||
};
|
||||
|
||||
RenderOutputType::CanvasFrame(frame)
|
||||
} else {
|
||||
let texture = exec.render_vello_scene_to_texture(&scene, resolution, context, background).await.expect("Failed to render Vello scene");
|
||||
|
||||
RenderOutputType::Texture(ImageTexture { texture })
|
||||
}
|
||||
}
|
||||
_ => unreachable!("Render node did not receive its requested data type"),
|
||||
};
|
||||
RenderOutput { data, metadata }
|
||||
}
|
||||
43
node-graph/nodes/gstd/src/text.rs
Normal file
43
node-graph/nodes/gstd/src/text.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use core_types::{Ctx, table::Table};
|
||||
use graph_craft::wasm_application_io::WasmEditorApi;
|
||||
use graphic_types::Vector;
|
||||
pub use text_nodes::*;
|
||||
|
||||
#[node_macro::node(category(""))]
|
||||
fn text<'i: 'n>(
|
||||
_: impl Ctx,
|
||||
editor: &'i WasmEditorApi,
|
||||
text: String,
|
||||
font_name: Font,
|
||||
#[unit(" px")]
|
||||
#[default(24.)]
|
||||
font_size: f64,
|
||||
#[unit("x")]
|
||||
#[default(1.2)]
|
||||
line_height_ratio: f64,
|
||||
#[unit(" px")]
|
||||
#[default(0.)]
|
||||
character_spacing: f64,
|
||||
#[unit(" px")] max_width: Option<f64>,
|
||||
#[unit(" px")] max_height: Option<f64>,
|
||||
/// Faux italic.
|
||||
#[unit("°")]
|
||||
#[default(0.)]
|
||||
tilt: f64,
|
||||
align: TextAlign,
|
||||
/// Splits each text glyph into its own row in the table of vector geometry.
|
||||
#[default(false)]
|
||||
per_glyph_instances: bool,
|
||||
) -> Table<Vector> {
|
||||
let typesetting = TypesettingConfig {
|
||||
font_size,
|
||||
line_height_ratio,
|
||||
character_spacing,
|
||||
max_width,
|
||||
max_height,
|
||||
tilt,
|
||||
align,
|
||||
};
|
||||
|
||||
to_path(&text, &font_name, &editor.font_cache, typesetting, per_glyph_instances)
|
||||
}
|
||||
213
node-graph/nodes/gstd/src/wasm_application_io.rs
Normal file
213
node-graph/nodes/gstd/src/wasm_application_io.rs
Normal file
@@ -0,0 +1,213 @@
|
||||
#[cfg(target_family = "wasm")]
|
||||
use base64::Engine;
|
||||
#[cfg(target_family = "wasm")]
|
||||
use core_types::WasmNotSend;
|
||||
#[cfg(target_family = "wasm")]
|
||||
use core_types::math::bbox::Bbox;
|
||||
use core_types::table::Table;
|
||||
#[cfg(target_family = "wasm")]
|
||||
use core_types::transform::Footprint;
|
||||
use core_types::{Color, Ctx};
|
||||
pub use graph_craft::document::value::RenderOutputType;
|
||||
pub use graph_craft::wasm_application_io::*;
|
||||
use graphene_application_io::ApplicationIo;
|
||||
#[cfg(target_family = "wasm")]
|
||||
use graphic_types::Graphic;
|
||||
#[cfg(target_family = "wasm")]
|
||||
use graphic_types::Vector;
|
||||
use graphic_types::raster_types::Image;
|
||||
use graphic_types::raster_types::{CPU, Raster};
|
||||
#[cfg(target_family = "wasm")]
|
||||
use graphic_types::vector_types::gradient::GradientStops;
|
||||
#[cfg(target_family = "wasm")]
|
||||
use rendering::{Render, RenderParams, RenderSvgSegmentList, SvgRender};
|
||||
use std::sync::Arc;
|
||||
#[cfg(target_family = "wasm")]
|
||||
use wasm_bindgen::JsCast;
|
||||
#[cfg(target_family = "wasm")]
|
||||
use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement};
|
||||
|
||||
#[cfg(feature = "wgpu")]
|
||||
#[node_macro::node(category("Debug: GPU"))]
|
||||
async fn create_surface<'a: 'n>(_: impl Ctx, editor: &'a WasmEditorApi) -> Arc<WasmSurfaceHandle> {
|
||||
Arc::new(editor.application_io.as_ref().unwrap().create_window())
|
||||
}
|
||||
|
||||
#[node_macro::node(category("Web Request"))]
|
||||
async fn get_request(_: impl Ctx, _primary: (), #[name("URL")] url: String, discard_result: bool) -> String {
|
||||
#[cfg(target_family = "wasm")]
|
||||
{
|
||||
if discard_result {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let _ = reqwest::get(url).await;
|
||||
});
|
||||
return String::new();
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
{
|
||||
#[cfg(feature = "tokio")]
|
||||
if discard_result {
|
||||
tokio::spawn(async move {
|
||||
let _ = reqwest::get(url).await;
|
||||
});
|
||||
return String::new();
|
||||
}
|
||||
#[cfg(not(feature = "tokio"))]
|
||||
if discard_result {
|
||||
return String::new();
|
||||
}
|
||||
}
|
||||
|
||||
let Ok(response) = reqwest::get(url).await else { return String::new() };
|
||||
response.text().await.ok().unwrap_or_default()
|
||||
}
|
||||
|
||||
#[node_macro::node(category("Web Request"))]
|
||||
async fn post_request(_: impl Ctx, _primary: (), #[name("URL")] url: String, body: Vec<u8>, discard_result: bool) -> String {
|
||||
#[cfg(target_family = "wasm")]
|
||||
{
|
||||
if discard_result {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let _ = reqwest::Client::new().post(url).body(body).header("Content-Type", "application/octet-stream").send().await;
|
||||
});
|
||||
return String::new();
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
{
|
||||
#[cfg(feature = "tokio")]
|
||||
if discard_result {
|
||||
let url = url.clone();
|
||||
let body = body.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = reqwest::Client::new().post(url).body(body).header("Content-Type", "application/octet-stream").send().await;
|
||||
});
|
||||
return String::new();
|
||||
}
|
||||
#[cfg(not(feature = "tokio"))]
|
||||
if discard_result {
|
||||
return String::new();
|
||||
}
|
||||
}
|
||||
|
||||
let Ok(response) = reqwest::Client::new().post(url).body(body).header("Content-Type", "application/octet-stream").send().await else {
|
||||
return String::new();
|
||||
};
|
||||
response.text().await.ok().unwrap_or_default()
|
||||
}
|
||||
|
||||
#[node_macro::node(category("Web Request"), name("String to Bytes"))]
|
||||
fn string_to_bytes(_: impl Ctx, string: String) -> Vec<u8> {
|
||||
string.into_bytes()
|
||||
}
|
||||
|
||||
#[node_macro::node(category("Web Request"), name("Image to Bytes"))]
|
||||
fn image_to_bytes(_: impl Ctx, image: Table<Raster<CPU>>) -> Vec<u8> {
|
||||
let Some(image) = image.iter().next() else { return vec![] };
|
||||
image.element.data.iter().flat_map(|color| color.to_rgb8_srgb().into_iter()).collect::<Vec<u8>>()
|
||||
}
|
||||
|
||||
#[node_macro::node(category("Web Request"))]
|
||||
async fn load_resource<'a: 'n>(_: impl Ctx, _primary: (), #[scope("editor-api")] editor: &'a WasmEditorApi, #[name("URL")] url: String) -> Arc<[u8]> {
|
||||
let Some(api) = editor.application_io.as_ref() else {
|
||||
return Arc::from(include_bytes!("../../../graph-craft/src/null.png").to_vec());
|
||||
};
|
||||
let Ok(data) = api.load_resource(url) else {
|
||||
return Arc::from(include_bytes!("../../../graph-craft/src/null.png").to_vec());
|
||||
};
|
||||
let Ok(data) = data.await else {
|
||||
return Arc::from(include_bytes!("../../../graph-craft/src/null.png").to_vec());
|
||||
};
|
||||
|
||||
data
|
||||
}
|
||||
|
||||
#[node_macro::node(category("Web Request"))]
|
||||
fn decode_image(_: impl Ctx, data: Arc<[u8]>) -> Table<Raster<CPU>> {
|
||||
let Some(image) = image::load_from_memory(data.as_ref()).ok() else {
|
||||
return Table::new();
|
||||
};
|
||||
let image = image.to_rgba32f();
|
||||
let image = Image {
|
||||
data: image
|
||||
.chunks(4)
|
||||
.map(|pixel| Color::from_unassociated_alpha(pixel[0], pixel[1], pixel[2], pixel[3]).to_linear_srgb())
|
||||
.collect(),
|
||||
width: image.width(),
|
||||
height: image.height(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
Table::new_from_element(Raster::new_cpu(image))
|
||||
}
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
#[node_macro::node(category(""))]
|
||||
async fn rasterize<T: WasmNotSend + 'n>(
|
||||
_: impl Ctx,
|
||||
#[implementations(
|
||||
Table<Vector>,
|
||||
Table<Raster<CPU>>,
|
||||
Table<Graphic>,
|
||||
Table<Color>,
|
||||
Table<GradientStops>,
|
||||
)]
|
||||
mut data: Table<T>,
|
||||
footprint: Footprint,
|
||||
surface_handle: Arc<graphene_application_io::SurfaceHandle<HtmlCanvasElement>>,
|
||||
) -> Table<Raster<CPU>>
|
||||
where
|
||||
Table<T>: Render,
|
||||
{
|
||||
use core_types::table::TableRow;
|
||||
|
||||
if footprint.transform.matrix2.determinant() == 0. {
|
||||
log::trace!("Invalid footprint received for rasterization");
|
||||
return Table::new();
|
||||
}
|
||||
|
||||
let mut render = SvgRender::new();
|
||||
let aabb = Bbox::from_transform(footprint.transform).to_axis_aligned_bbox();
|
||||
let size = aabb.size();
|
||||
let resolution = footprint.resolution;
|
||||
let render_params = RenderParams {
|
||||
footprint,
|
||||
for_export: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for row in data.iter_mut() {
|
||||
*row.transform = glam::DAffine2::from_translation(-aabb.start) * *row.transform;
|
||||
}
|
||||
data.render_svg(&mut render, &render_params);
|
||||
render.format_svg(glam::DVec2::ZERO, size);
|
||||
let svg_string = render.svg.to_svg_string();
|
||||
|
||||
let canvas = &surface_handle.surface;
|
||||
canvas.set_width(resolution.x);
|
||||
canvas.set_height(resolution.y);
|
||||
|
||||
let context = canvas.get_context("2d").unwrap().unwrap().dyn_into::<CanvasRenderingContext2d>().unwrap();
|
||||
|
||||
let preamble = "data:image/svg+xml;base64,";
|
||||
let mut base64_string = String::with_capacity(preamble.len() + svg_string.len() * 4);
|
||||
base64_string.push_str(preamble);
|
||||
base64::engine::general_purpose::STANDARD.encode_string(svg_string, &mut base64_string);
|
||||
|
||||
let image_data = web_sys::HtmlImageElement::new().unwrap();
|
||||
image_data.set_src(base64_string.as_str());
|
||||
wasm_bindgen_futures::JsFuture::from(image_data.decode()).await.unwrap();
|
||||
context
|
||||
.draw_image_with_html_image_element_and_dw_and_dh(&image_data, 0., 0., resolution.x as f64, resolution.y as f64)
|
||||
.unwrap();
|
||||
|
||||
let rasterized = context.get_image_data(0., 0., resolution.x as f64, resolution.y as f64).unwrap();
|
||||
|
||||
let image = Image::from_image_data(&rasterized.data().0, resolution.x as u32, resolution.y as u32);
|
||||
Table::new_from_row(TableRow {
|
||||
element: Raster::new_cpu(image),
|
||||
transform: footprint.transform,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
BIN
node-graph/nodes/gstd/test-image-1-result.png
Normal file
BIN
node-graph/nodes/gstd/test-image-1-result.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
BIN
node-graph/nodes/gstd/test-image-1.png
Normal file
BIN
node-graph/nodes/gstd/test-image-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
18
node-graph/nodes/math/Cargo.toml
Normal file
18
node-graph/nodes/math/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "math-nodes"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Math operation nodes for Graphene"
|
||||
authors = ["Graphite Authors <contact@graphite.rs>"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[dependencies]
|
||||
core-types = { workspace = true }
|
||||
node-macro = { workspace = true }
|
||||
vector-types = { workspace = true }
|
||||
|
||||
# Workspace dependencies
|
||||
glam = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
math-parser = { workspace = true }
|
||||
log = { workspace = true }
|
||||
831
node-graph/nodes/math/src/lib.rs
Normal file
831
node-graph/nodes/math/src/lib.rs
Normal file
@@ -0,0 +1,831 @@
|
||||
use core_types::registry::types::{Fraction, Percentage, PixelSize, TextArea};
|
||||
use core_types::table::Table;
|
||||
use core_types::transform::Footprint;
|
||||
use core_types::{Color, Ctx, num_traits};
|
||||
use glam::{DAffine2, DVec2};
|
||||
use log::warn;
|
||||
use math_parser::ast;
|
||||
use math_parser::context::{EvalContext, NothingMap, ValueProvider};
|
||||
use math_parser::value::{Number, Value};
|
||||
use num_traits::Pow;
|
||||
use rand::{Rng, SeedableRng};
|
||||
use std::ops::{Add, Div, Mul, Rem, Sub};
|
||||
use vector_types::GradientStops;
|
||||
|
||||
/// The struct that stores the context for the maths parser.
|
||||
/// This is currently just limited to supplying `a` and `b` until we add better node graph support and UI for variadic inputs.
|
||||
struct MathNodeContext {
|
||||
a: f64,
|
||||
b: f64,
|
||||
}
|
||||
|
||||
impl ValueProvider for MathNodeContext {
|
||||
fn get_value(&self, name: &str) -> Option<Value> {
|
||||
if name.eq_ignore_ascii_case("a") {
|
||||
Some(Value::from_f64(self.a))
|
||||
} else if name.eq_ignore_ascii_case("b") {
|
||||
Some(Value::from_f64(self.b))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculates a mathematical expression with input values "A" and "B"
|
||||
#[node_macro::node(category("Math: Arithmetic"), properties("math_properties"))]
|
||||
fn math<T: num_traits::float::Float>(
|
||||
_: impl Ctx,
|
||||
/// The value of "A" when calculating the expression
|
||||
#[implementations(f64, f32)]
|
||||
operand_a: T,
|
||||
/// A math expression that may incorporate "A" and/or "B", such as "sqrt(A + B) - B^2"
|
||||
#[default(A + B)]
|
||||
expression: String,
|
||||
/// The value of "B" when calculating the expression
|
||||
#[implementations(f64, f32)]
|
||||
#[default(1.)]
|
||||
operand_b: T,
|
||||
) -> T {
|
||||
let (node, _unit) = match ast::Node::try_parse_from_str(&expression) {
|
||||
Ok(expr) => expr,
|
||||
Err(e) => {
|
||||
warn!("Invalid expression: `{expression}`\n{e:?}");
|
||||
return T::from(0.).unwrap();
|
||||
}
|
||||
};
|
||||
let context = EvalContext::new(
|
||||
MathNodeContext {
|
||||
a: operand_a.to_f64().unwrap(),
|
||||
b: operand_b.to_f64().unwrap(),
|
||||
},
|
||||
NothingMap,
|
||||
);
|
||||
|
||||
let value = match node.eval(&context) {
|
||||
Ok(value) => value,
|
||||
Err(e) => {
|
||||
warn!("Expression evaluation error: {e:?}");
|
||||
return T::from(0.).unwrap();
|
||||
}
|
||||
};
|
||||
|
||||
let Value::Number(num) = value;
|
||||
match num {
|
||||
Number::Real(val) => T::from(val).unwrap(),
|
||||
Number::Complex(c) => T::from(c.re).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The addition operation (+) calculates the sum of two numbers.
|
||||
#[node_macro::node(category("Math: Arithmetic"))]
|
||||
fn add<U: Add<T>, T>(
|
||||
_: impl Ctx,
|
||||
/// The left-hand side of the addition operation.
|
||||
#[implementations(f64, f32, u32, DVec2, f64, DVec2)]
|
||||
augend: U,
|
||||
/// The right-hand side of the addition operation.
|
||||
#[implementations(f64, f32, u32, DVec2, DVec2, f64)]
|
||||
addend: T,
|
||||
) -> <U as Add<T>>::Output {
|
||||
augend + addend
|
||||
}
|
||||
|
||||
/// The subtraction operation (-) calculates the difference between two numbers.
|
||||
#[node_macro::node(category("Math: Arithmetic"))]
|
||||
fn subtract<U: Sub<T>, T>(
|
||||
_: impl Ctx,
|
||||
/// The left-hand side of the subtraction operation.
|
||||
#[implementations(f64, f32, u32, DVec2, f64, DVec2)]
|
||||
minuend: U,
|
||||
/// The right-hand side of the subtraction operation.
|
||||
#[implementations(f64, f32, u32, DVec2, DVec2, f64)]
|
||||
subtrahend: T,
|
||||
) -> <U as Sub<T>>::Output {
|
||||
minuend - subtrahend
|
||||
}
|
||||
|
||||
/// The multiplication operation (×) calculates the product of two numbers.
|
||||
#[node_macro::node(category("Math: Arithmetic"))]
|
||||
fn multiply<U: Mul<T>, T>(
|
||||
_: impl Ctx,
|
||||
/// The left-hand side of the multiplication operation.
|
||||
#[implementations(f64, f32, u32, DVec2, f64, DVec2, DAffine2)]
|
||||
multiplier: U,
|
||||
/// The right-hand side of the multiplication operation.
|
||||
#[default(1.)]
|
||||
#[implementations(f64, f32, u32, DVec2, DVec2, f64, DAffine2)]
|
||||
multiplicand: T,
|
||||
) -> <U as Mul<T>>::Output {
|
||||
multiplier * multiplicand
|
||||
}
|
||||
|
||||
/// The division operation (÷) calculates the quotient of two numbers.
|
||||
///
|
||||
/// Produces 0 if the denominator is 0.
|
||||
#[node_macro::node(category("Math: Arithmetic"))]
|
||||
fn divide<U: Div<T> + Default + PartialEq, T: Default + PartialEq>(
|
||||
_: impl Ctx,
|
||||
/// The left-hand side of the division operation.
|
||||
#[implementations(f64, f32, u32, DVec2, DVec2, f64)]
|
||||
numerator: U,
|
||||
/// The right-hand side of the division operation.
|
||||
#[default(1.)]
|
||||
#[implementations(f64, f32, u32, DVec2, f64, DVec2)]
|
||||
denominator: T,
|
||||
) -> <U as Div<T>>::Output
|
||||
where
|
||||
<U as Div<T>>::Output: Default,
|
||||
{
|
||||
if denominator == T::default() {
|
||||
return <U as Div<T>>::Output::default();
|
||||
}
|
||||
numerator / denominator
|
||||
}
|
||||
|
||||
/// The modulo operation (%) calculates the remainder from the division of two numbers. The sign of the result shares the sign of the numerator unless "Always Positive" is enabled.
|
||||
#[node_macro::node(category("Math: Arithmetic"))]
|
||||
fn modulo<U: Rem<T, Output: Add<T, Output: Rem<T, Output = U::Output>>>, T: Copy>(
|
||||
_: impl Ctx,
|
||||
/// The left-hand side of the modulo operation.
|
||||
#[implementations(f64, f32, u32, DVec2, DVec2, f64)]
|
||||
numerator: U,
|
||||
/// The right-hand side of the modulo operation.
|
||||
#[default(2.)]
|
||||
#[implementations(f64, f32, u32, DVec2, f64, DVec2)]
|
||||
modulus: T,
|
||||
/// Ensures the result will always be positive, even if the numerator is negative.
|
||||
#[default(true)]
|
||||
always_positive: bool,
|
||||
) -> <U as Rem<T>>::Output {
|
||||
if always_positive { (numerator % modulus + modulus) % modulus } else { numerator % modulus }
|
||||
}
|
||||
|
||||
/// The exponent operation (^) calculates the result of raising a number to a power.
|
||||
#[node_macro::node(category("Math: Arithmetic"))]
|
||||
fn exponent<T: Pow<T>>(
|
||||
_: impl Ctx,
|
||||
/// The base number that will be raised to the power.
|
||||
#[implementations(f64, f32, u32)]
|
||||
base: T,
|
||||
/// The power to which the base number will be raised.
|
||||
#[implementations(f64, f32, u32)]
|
||||
#[default(2.)]
|
||||
power: T,
|
||||
) -> <T as num_traits::Pow<T>>::Output {
|
||||
base.pow(power)
|
||||
}
|
||||
|
||||
/// The square root operation (√) calculates the nth root of a number, equivalent to raising the number to the power of 1/n.
|
||||
#[node_macro::node(category("Math: Arithmetic"))]
|
||||
fn root<T: num_traits::float::Float>(
|
||||
_: impl Ctx,
|
||||
/// The number for which the nth root will be calculated.
|
||||
#[default(2.)]
|
||||
#[implementations(f64, f32)]
|
||||
radicand: T,
|
||||
/// The degree of the root to be calculated. Square root is 2, cube root is 3, and so on.
|
||||
#[default(2.)]
|
||||
#[implementations(f64, f32)]
|
||||
degree: T,
|
||||
) -> T {
|
||||
if degree == T::from(2.).unwrap() {
|
||||
radicand.sqrt()
|
||||
} else if degree == T::from(3.).unwrap() {
|
||||
radicand.cbrt()
|
||||
} else {
|
||||
radicand.powf(T::from(1.).unwrap() / degree)
|
||||
}
|
||||
}
|
||||
|
||||
/// The logarithmic function (log) calculates the logarithm of a number with a specified base. If the natural logarithm function (ln) is desired, set the base to "e".
|
||||
#[node_macro::node(category("Math: Arithmetic"))]
|
||||
fn logarithm<T: num_traits::float::Float>(
|
||||
_: impl Ctx,
|
||||
/// The number for which the logarithm will be calculated.
|
||||
#[implementations(f64, f32)]
|
||||
value: T,
|
||||
/// The base of the logarithm, such as 2 (binary), 10 (decimal), and e (natural logarithm).
|
||||
#[default(2.)]
|
||||
#[implementations(f64, f32)]
|
||||
base: T,
|
||||
) -> T {
|
||||
if base == T::from(2.).unwrap() {
|
||||
value.log2()
|
||||
} else if base == T::from(10.).unwrap() {
|
||||
value.log10()
|
||||
} else if base - T::from(std::f64::consts::E).unwrap() < T::epsilon() * T::from(1e6).unwrap() {
|
||||
value.ln()
|
||||
} else {
|
||||
value.log(base)
|
||||
}
|
||||
}
|
||||
|
||||
/// The sine trigonometric function (sin) calculates the ratio of the angle's opposite side length to its hypotenuse length.
|
||||
#[node_macro::node(category("Math: Trig"))]
|
||||
fn sine<T: num_traits::float::Float>(
|
||||
_: impl Ctx,
|
||||
/// The given angle.
|
||||
#[implementations(f64, f32)]
|
||||
theta: T,
|
||||
/// Whether the given angle should be interpreted as radians instead of degrees.
|
||||
radians: bool,
|
||||
) -> T {
|
||||
if radians { theta.sin() } else { theta.to_radians().sin() }
|
||||
}
|
||||
|
||||
/// The cosine trigonometric function (cos) calculates the ratio of the angle's adjacent side length to its hypotenuse length.
|
||||
#[node_macro::node(category("Math: Trig"))]
|
||||
fn cosine<T: num_traits::float::Float>(
|
||||
_: impl Ctx,
|
||||
/// The given angle.
|
||||
#[implementations(f64, f32)]
|
||||
theta: T,
|
||||
/// Whether the given angle should be interpreted as radians instead of degrees.
|
||||
radians: bool,
|
||||
) -> T {
|
||||
if radians { theta.cos() } else { theta.to_radians().cos() }
|
||||
}
|
||||
|
||||
/// The tangent trigonometric function (tan) calculates the ratio of the angle's opposite side length to its adjacent side length.
|
||||
#[node_macro::node(category("Math: Trig"))]
|
||||
fn tangent<T: num_traits::float::Float>(
|
||||
_: impl Ctx,
|
||||
/// The given angle.
|
||||
#[implementations(f64, f32)]
|
||||
theta: T,
|
||||
/// Whether the given angle should be interpreted as radians instead of degrees.
|
||||
radians: bool,
|
||||
) -> T {
|
||||
if radians { theta.tan() } else { theta.to_radians().tan() }
|
||||
}
|
||||
|
||||
/// The inverse sine trigonometric function (asin) calculates the angle whose sine is the specified value.
|
||||
#[node_macro::node(category("Math: Trig"))]
|
||||
fn sine_inverse<T: num_traits::float::Float>(
|
||||
_: impl Ctx,
|
||||
/// The given value for which the angle will be calculated. Must be in the range [-1, 1] or else the result will be NaN.
|
||||
#[implementations(f64, f32)]
|
||||
value: T,
|
||||
/// Whether the resulting angle should be given in as radians instead of degrees.
|
||||
radians: bool,
|
||||
) -> T {
|
||||
if radians { value.asin() } else { value.asin().to_degrees() }
|
||||
}
|
||||
|
||||
/// The inverse cosine trigonometric function (acos) calculates the angle whose cosine is the specified value.
|
||||
#[node_macro::node(category("Math: Trig"))]
|
||||
fn cosine_inverse<T: num_traits::float::Float>(
|
||||
_: impl Ctx,
|
||||
/// The given value for which the angle will be calculated. Must be in the range [-1, 1] or else the result will be NaN.
|
||||
#[implementations(f64, f32)]
|
||||
value: T,
|
||||
/// Whether the resulting angle should be given in as radians instead of degrees.
|
||||
radians: bool,
|
||||
) -> T {
|
||||
if radians { value.acos() } else { value.acos().to_degrees() }
|
||||
}
|
||||
|
||||
/// The inverse tangent trigonometric function (atan or atan2, depending on input type) calculates:
|
||||
/// atan: the angle whose tangent is the specified scalar number.
|
||||
/// atan2: the angle of a ray from the origin to the specified vec2.
|
||||
///
|
||||
/// The resulting angle is always in the range [-90°, 90°] or, in radians, [-π/2, π/2].
|
||||
#[node_macro::node(category("Math: Trig"))]
|
||||
fn tangent_inverse<T: TangentInverse>(
|
||||
_: impl Ctx,
|
||||
/// The given value for which the angle will be calculated.
|
||||
#[implementations(f64, f32, DVec2)]
|
||||
value: T,
|
||||
/// Whether the resulting angle should be given in as radians instead of degrees.
|
||||
radians: bool,
|
||||
) -> T::Output {
|
||||
value.atan(radians)
|
||||
}
|
||||
|
||||
pub trait TangentInverse {
|
||||
type Output: num_traits::float::Float;
|
||||
fn atan(self, radians: bool) -> Self::Output;
|
||||
}
|
||||
impl TangentInverse for f32 {
|
||||
type Output = f32;
|
||||
fn atan(self, radians: bool) -> Self::Output {
|
||||
if radians { self.atan() } else { self.atan().to_degrees() }
|
||||
}
|
||||
}
|
||||
impl TangentInverse for f64 {
|
||||
type Output = f64;
|
||||
fn atan(self, radians: bool) -> Self::Output {
|
||||
if radians { self.atan() } else { self.atan().to_degrees() }
|
||||
}
|
||||
}
|
||||
impl TangentInverse for DVec2 {
|
||||
type Output = f64;
|
||||
fn atan(self, radians: bool) -> Self::Output {
|
||||
if radians { self.y.atan2(self.x) } else { self.y.atan2(self.x).to_degrees() }
|
||||
}
|
||||
}
|
||||
|
||||
#[node_macro::node(category("Math: Numeric"))]
|
||||
fn remap<U: num_traits::float::Float>(
|
||||
_: impl Ctx,
|
||||
#[implementations(f64, f32)] value: U,
|
||||
#[implementations(f64, f32)] input_min: U,
|
||||
#[implementations(f64, f32)]
|
||||
#[default(1.)]
|
||||
input_max: U,
|
||||
#[implementations(f64, f32)] output_min: U,
|
||||
#[implementations(f64, f32)]
|
||||
#[default(1.)]
|
||||
output_max: U,
|
||||
clamped: bool,
|
||||
) -> U {
|
||||
let input_range = input_max - input_min;
|
||||
|
||||
// Handle division by zero
|
||||
if input_range.abs() < U::epsilon() {
|
||||
return output_min;
|
||||
}
|
||||
|
||||
let normalized = (value - input_min) / input_range;
|
||||
let output_range = output_max - output_min;
|
||||
|
||||
let result = output_min + normalized * output_range;
|
||||
|
||||
if clamped {
|
||||
// Handle both normal and inverted ranges, since we want to allow the user to use this node to also reverse a range.
|
||||
if output_min <= output_max {
|
||||
result.clamp(output_min, output_max)
|
||||
} else {
|
||||
result.clamp(output_max, output_min)
|
||||
}
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// The random function (rand) converts a seed into a random number within the specified range, inclusive of the minimum and exclusive of the maximum. The minimum and maximum values are automatically swapped if they are reversed.
|
||||
#[node_macro::node(category("Math: Numeric"))]
|
||||
fn random(
|
||||
_: impl Ctx,
|
||||
_primary: (),
|
||||
/// Seed to determine the unique variation of which number will be generated.
|
||||
seed: u64,
|
||||
/// The smaller end of the range within which the random number will be generated.
|
||||
#[default(0.)]
|
||||
min: f64,
|
||||
/// The larger end of the range within which the random number will be generated.
|
||||
#[default(1.)]
|
||||
max: f64,
|
||||
) -> f64 {
|
||||
let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
|
||||
let result = rng.random::<f64>();
|
||||
let (min, max) = if min < max { (min, max) } else { (max, min) };
|
||||
result * (max - min) + min
|
||||
}
|
||||
|
||||
// TODO: Test that these are no longer needed in all circumstances, then remove them and add a migration to convert these into Passthrough nodes. Note: these act more as type annotations than as identity functions.
|
||||
/// Convert a number to an integer of the type u32, which may be the required type for certain node inputs.
|
||||
#[node_macro::node(name("To u32"), category("Debug"))]
|
||||
fn to_u32(_: impl Ctx, value: u32) -> u32 {
|
||||
value
|
||||
}
|
||||
|
||||
// TODO: Test that these are no longer needed in all circumstances, then remove them and add a migration to convert these into Passthrough nodes. Note: these act more as type annotations than as identity functions.
|
||||
/// Convert a number to an integer of the type u64, which may be the required type for certain node inputs.
|
||||
#[node_macro::node(name("To u64"), category("Debug"))]
|
||||
fn to_u64(_: impl Ctx, value: u64) -> u64 {
|
||||
value
|
||||
}
|
||||
|
||||
// TODO: Test that these are no longer needed in all circumstances, then remove them and add a migration to convert these into Passthrough nodes. Note: these act more as type annotations than as identity functions.
|
||||
/// Convert an integer to a decimal number of the type f64, which may be the required type for certain node inputs.
|
||||
#[node_macro::node(name("To f64"), category("Debug"))]
|
||||
fn to_f64(_: impl Ctx, value: f64) -> f64 {
|
||||
value
|
||||
}
|
||||
|
||||
/// The rounding function (round) maps an input value to its nearest whole number. Halfway values are rounded away from zero.
|
||||
#[node_macro::node(category("Math: Numeric"))]
|
||||
fn round<T: num_traits::float::Float>(
|
||||
_: impl Ctx,
|
||||
/// The number which will be rounded.
|
||||
#[implementations(f64, f32)]
|
||||
value: T,
|
||||
) -> T {
|
||||
value.round()
|
||||
}
|
||||
|
||||
/// The floor function (floor) rounds down an input value to the nearest whole number, unless the input number is already whole.
|
||||
#[node_macro::node(category("Math: Numeric"))]
|
||||
fn floor<T: num_traits::float::Float>(
|
||||
_: impl Ctx,
|
||||
/// The number which will be rounded down.
|
||||
#[implementations(f64, f32)]
|
||||
value: T,
|
||||
) -> T {
|
||||
value.floor()
|
||||
}
|
||||
|
||||
/// The ceiling function (ceil) rounds up an input value to the nearest whole number, unless the input number is already whole.
|
||||
#[node_macro::node(category("Math: Numeric"))]
|
||||
fn ceiling<T: num_traits::float::Float>(
|
||||
_: impl Ctx,
|
||||
/// The number which will be rounded up.
|
||||
#[implementations(f64, f32)]
|
||||
value: T,
|
||||
) -> T {
|
||||
value.ceil()
|
||||
}
|
||||
|
||||
/// The absolute value function (abs) removes the negative sign from an input value, if present.
|
||||
#[node_macro::node(category("Math: Numeric"))]
|
||||
fn absolute_value<T: num_traits::sign::Signed>(
|
||||
_: impl Ctx,
|
||||
/// The number which will be made positive.
|
||||
#[implementations(f64, f32, i32, i64)]
|
||||
value: T,
|
||||
) -> T {
|
||||
value.abs()
|
||||
}
|
||||
|
||||
/// The minimum function (min) picks the smaller of two numbers.
|
||||
#[node_macro::node(category("Math: Numeric"))]
|
||||
fn min<T: std::cmp::PartialOrd>(
|
||||
_: impl Ctx,
|
||||
/// One of the two numbers, of which the lesser will be returned.
|
||||
#[implementations(f64, f32, u32, &str)]
|
||||
value: T,
|
||||
/// The other of the two numbers, of which the lesser will be returned.
|
||||
#[implementations(f64, f32, u32, &str)]
|
||||
other_value: T,
|
||||
) -> T {
|
||||
if value < other_value { value } else { other_value }
|
||||
}
|
||||
|
||||
/// The maximum function (max) picks the larger of two numbers.
|
||||
#[node_macro::node(category("Math: Numeric"))]
|
||||
fn max<T: std::cmp::PartialOrd>(
|
||||
_: impl Ctx,
|
||||
/// One of the two numbers, of which the greater will be returned.
|
||||
#[implementations(f64, f32, u32, &str)]
|
||||
value: T,
|
||||
/// The other of the two numbers, of which the greater will be returned.
|
||||
#[implementations(f64, f32, u32, &str)]
|
||||
other_value: T,
|
||||
) -> T {
|
||||
if value > other_value { value } else { other_value }
|
||||
}
|
||||
|
||||
/// The clamp function (clamp) restricts a number to a specified range between a minimum and maximum value. The minimum and maximum values are automatically swapped if they are reversed.
|
||||
#[node_macro::node(category("Math: Numeric"))]
|
||||
fn clamp<T: std::cmp::PartialOrd>(
|
||||
_: impl Ctx,
|
||||
/// The number to be clamped, which will be restricted to the range between the minimum and maximum values.
|
||||
#[implementations(f64, f32, u32, &str)]
|
||||
value: T,
|
||||
/// The left (smaller) side of the range. The output will never be less than this number.
|
||||
#[implementations(f64, f32, u32, &str)]
|
||||
min: T,
|
||||
/// The right (greater) side of the range. The output will never be greater than this number.
|
||||
#[implementations(f64, f32, u32, &str)]
|
||||
max: T,
|
||||
) -> T {
|
||||
let (min, max) = if min < max { (min, max) } else { (max, min) };
|
||||
if value < min {
|
||||
min
|
||||
} else if value > max {
|
||||
max
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
/// The greatest common divisor (GCD) calculates the largest positive integer that divides both of the two input numbers without leaving a remainder.
|
||||
#[node_macro::node(category("Math: Numeric"))]
|
||||
fn greatest_common_divisor<T: num_traits::int::PrimInt + std::ops::ShrAssign<i32> + std::ops::SubAssign>(
|
||||
_: impl Ctx,
|
||||
/// One of the two numbers for which the GCD will be calculated.
|
||||
#[implementations(u32, u64, i32)]
|
||||
value: T,
|
||||
/// The other of the two numbers for which the GCD will be calculated.
|
||||
#[implementations(u32, u64, i32)]
|
||||
other_value: T,
|
||||
) -> T {
|
||||
if value == T::zero() {
|
||||
return other_value;
|
||||
}
|
||||
if other_value == T::zero() {
|
||||
return value;
|
||||
}
|
||||
binary_gcd(value, other_value)
|
||||
}
|
||||
|
||||
/// The least common multiple (LCM) calculates the smallest positive integer that is a multiple of both of the two input numbers.
|
||||
#[node_macro::node(category("Math: Numeric"))]
|
||||
fn least_common_multiple<T: num_traits::ToPrimitive + num_traits::FromPrimitive + num_traits::identities::Zero>(
|
||||
_: impl Ctx,
|
||||
/// One of the two numbers for which the LCM will be calculated.
|
||||
#[implementations(u32, u64, i32)]
|
||||
value: T,
|
||||
/// The other of the two numbers for which the LCM will be calculated.
|
||||
#[implementations(u32, u64, i32)]
|
||||
other_value: T,
|
||||
) -> T {
|
||||
let value = value.to_i128().unwrap();
|
||||
let other_value = other_value.to_i128().unwrap();
|
||||
|
||||
if value == 0 || other_value == 0 {
|
||||
return T::zero();
|
||||
}
|
||||
let gcd = binary_gcd(value, other_value);
|
||||
|
||||
T::from_i128((value * other_value).abs() / gcd).unwrap()
|
||||
}
|
||||
|
||||
fn binary_gcd<T: num_traits::int::PrimInt + std::ops::ShrAssign<i32> + std::ops::SubAssign>(mut a: T, mut b: T) -> T {
|
||||
if a == T::zero() {
|
||||
return b;
|
||||
}
|
||||
if b == T::zero() {
|
||||
return a;
|
||||
}
|
||||
|
||||
let mut shift = 0;
|
||||
while (a | b) & T::one() == T::zero() {
|
||||
a >>= 1;
|
||||
b >>= 1;
|
||||
shift += 1;
|
||||
}
|
||||
|
||||
while a & T::one() == T::zero() {
|
||||
a >>= 1;
|
||||
}
|
||||
|
||||
while b != T::zero() {
|
||||
while b & T::one() == T::zero() {
|
||||
b >>= 1;
|
||||
}
|
||||
if a > b {
|
||||
std::mem::swap(&mut a, &mut b);
|
||||
}
|
||||
b -= a;
|
||||
}
|
||||
|
||||
a << shift
|
||||
}
|
||||
|
||||
/// The equality operation (==) compares two values and returns true if they are equal, or false if they are not.
|
||||
#[node_macro::node(category("Math: Logic"))]
|
||||
fn equals<T: std::cmp::PartialEq<T>>(
|
||||
_: impl Ctx,
|
||||
/// One of the two numbers to compare for equality.
|
||||
#[implementations(f64, f32, u32, DVec2, bool, &str, String)]
|
||||
value: T,
|
||||
/// The other of the two numbers to compare for equality.
|
||||
#[implementations(f64, f32, u32, DVec2, bool, &str, String)]
|
||||
other_value: T,
|
||||
) -> bool {
|
||||
other_value == value
|
||||
}
|
||||
|
||||
/// The inequality operation (!=) compares two values and returns true if they are not equal, or false if they are.
|
||||
#[node_macro::node(category("Math: Logic"))]
|
||||
fn not_equals<T: std::cmp::PartialEq<T>>(
|
||||
_: impl Ctx,
|
||||
/// One of the two numbers to compare for inequality.
|
||||
#[implementations(f64, f32, u32, DVec2, bool, &str)]
|
||||
value: T,
|
||||
/// The other of the two numbers to compare for inequality.
|
||||
#[implementations(f64, f32, u32, DVec2, bool, &str)]
|
||||
other_value: T,
|
||||
) -> bool {
|
||||
other_value != value
|
||||
}
|
||||
|
||||
/// The less-than operation (<) compares two values and returns true if the first value is less than the second, or false if it is not.
|
||||
/// If enabled with "Or Equal", the less-than-or-equal operation (<=) will be used instead.
|
||||
#[node_macro::node(category("Math: Logic"))]
|
||||
fn less_than<T: std::cmp::PartialOrd<T>>(
|
||||
_: impl Ctx,
|
||||
/// The number on the left-hand side of the comparison.
|
||||
#[implementations(f64, f32, u32)]
|
||||
value: T,
|
||||
/// The number on the right-hand side of the comparison.
|
||||
#[implementations(f64, f32, u32)]
|
||||
other_value: T,
|
||||
/// Uses the less-than-or-equal operation (<=) instead of the less-than operation (<).
|
||||
or_equal: bool,
|
||||
) -> bool {
|
||||
if or_equal { value <= other_value } else { value < other_value }
|
||||
}
|
||||
|
||||
/// The greater-than operation (>) compares two values and returns true if the first value is greater than the second, or false if it is not.
|
||||
/// If enabled with "Or Equal", the greater-than-or-equal operation (>=) will be used instead.
|
||||
#[node_macro::node(category("Math: Logic"))]
|
||||
fn greater_than<T: std::cmp::PartialOrd<T>>(
|
||||
_: impl Ctx,
|
||||
/// The number on the left-hand side of the comparison.
|
||||
#[implementations(f64, f32, u32)]
|
||||
value: T,
|
||||
/// The number on the right-hand side of the comparison.
|
||||
#[implementations(f64, f32, u32)]
|
||||
other_value: T,
|
||||
/// Uses the greater-than-or-equal operation (>=) instead of the greater-than operation (>).
|
||||
or_equal: bool,
|
||||
) -> bool {
|
||||
if or_equal { value >= other_value } else { value > other_value }
|
||||
}
|
||||
|
||||
/// The logical or operation (||) returns true if either of the two inputs are true, or false if both are false.
|
||||
#[node_macro::node(category("Math: Logic"))]
|
||||
fn logical_or(
|
||||
_: impl Ctx,
|
||||
/// One of the two boolean values, either of which may be true for the node to output true.
|
||||
value: bool,
|
||||
/// The other of the two boolean values, either of which may be true for the node to output true.
|
||||
other_value: bool,
|
||||
) -> bool {
|
||||
value || other_value
|
||||
}
|
||||
|
||||
/// The logical and operation (&&) returns true if both of the two inputs are true, or false if any are false.
|
||||
#[node_macro::node(category("Math: Logic"))]
|
||||
fn logical_and(
|
||||
_: impl Ctx,
|
||||
/// One of the two boolean values, both of which must be true for the node to output true.
|
||||
value: bool,
|
||||
/// The other of the two boolean values, both of which must be true for the node to output true.
|
||||
other_value: bool,
|
||||
) -> bool {
|
||||
value && other_value
|
||||
}
|
||||
|
||||
/// The logical not operation (!) reverses true and false value of the input.
|
||||
#[node_macro::node(category("Math: Logic"))]
|
||||
fn logical_not(
|
||||
_: impl Ctx,
|
||||
/// The boolean value to be reversed.
|
||||
input: bool,
|
||||
) -> bool {
|
||||
!input
|
||||
}
|
||||
|
||||
/// Constructs a bool value which may be set to true or false.
|
||||
#[node_macro::node(category("Value"))]
|
||||
fn bool_value(_: impl Ctx, _primary: (), #[name("Bool")] bool_value: bool) -> bool {
|
||||
bool_value
|
||||
}
|
||||
|
||||
/// Constructs a number value which may be set to any real number.
|
||||
#[node_macro::node(category("Value"))]
|
||||
fn number_value(_: impl Ctx, _primary: (), number: f64) -> f64 {
|
||||
number
|
||||
}
|
||||
|
||||
/// Constructs a number value which may be set to any value from 0% to 100% by dragging the slider.
|
||||
#[node_macro::node(category("Value"))]
|
||||
fn percentage_value(_: impl Ctx, _primary: (), percentage: Percentage) -> f64 {
|
||||
percentage
|
||||
}
|
||||
|
||||
/// Constructs a two-dimensional vector value which may be set to any XY pair.
|
||||
#[node_macro::node(category("Value"), name("Vec2 Value"))]
|
||||
fn vec2_value(_: impl Ctx, _primary: (), x: f64, y: f64) -> DVec2 {
|
||||
DVec2::new(x, y)
|
||||
}
|
||||
|
||||
/// Constructs a color value which may be set to any color, or no color.
|
||||
#[node_macro::node(category("Value"))]
|
||||
fn color_value(_: impl Ctx, _primary: (), #[default(Color::RED)] color: Table<Color>) -> Table<Color> {
|
||||
color
|
||||
}
|
||||
|
||||
/// Constructs a gradient value which may be set to any sequence of color stops to represent the transition between colors.
|
||||
#[node_macro::node(category("Value"))]
|
||||
fn gradient_value(_: impl Ctx, _primary: (), gradient: GradientStops) -> GradientStops {
|
||||
gradient
|
||||
}
|
||||
|
||||
/// Constructs a gradient value which may be set to any sequence of color stops to represent the transition between colors.
|
||||
#[node_macro::node(category("Value"))]
|
||||
fn gradient_table_value(_: impl Ctx, _primary: (), gradient: GradientStops) -> Table<GradientStops> {
|
||||
Table::new_from_element(gradient)
|
||||
}
|
||||
|
||||
/// Gets the color at the specified position along the gradient, given a position from 0 (left) to 1 (right).
|
||||
#[node_macro::node(category("Color"))]
|
||||
fn sample_gradient(_: impl Ctx, _primary: (), gradient: GradientStops, position: Fraction) -> Table<Color> {
|
||||
let position = position.clamp(0., 1.);
|
||||
let color = gradient.evaluate(position);
|
||||
Table::new_from_element(color)
|
||||
}
|
||||
|
||||
/// Constructs a string value which may be set to any plain text.
|
||||
#[node_macro::node(category("Value"))]
|
||||
fn string_value(_: impl Ctx, _primary: (), string: TextArea) -> String {
|
||||
string
|
||||
}
|
||||
|
||||
/// Constructs a footprint value which may be set to any transformation of a unit square describing a render area, and a render resolution at least 1x1 integer pixels.
|
||||
#[node_macro::node(category("Value"))]
|
||||
fn footprint_value(_: impl Ctx, _primary: (), transform: DAffine2, #[default(100., 100.)] resolution: PixelSize) -> Footprint {
|
||||
Footprint {
|
||||
transform,
|
||||
resolution: resolution.max(DVec2::ONE).as_uvec2(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[node_macro::node(category("Math: Vector"))]
|
||||
fn dot_product(_: impl Ctx, vector_a: DVec2, vector_b: DVec2) -> f64 {
|
||||
vector_a.dot(vector_b)
|
||||
}
|
||||
|
||||
/// Gets the length or magnitude of a vector.
|
||||
#[node_macro::node(category("Math: Vector"))]
|
||||
fn length(_: impl Ctx, vector: DVec2) -> f64 {
|
||||
vector.length()
|
||||
}
|
||||
|
||||
/// Scales the input vector to unit length while preserving it's direction. This is equivalent to dividing the input vector by it's own magnitude.
|
||||
///
|
||||
/// Returns zero when the input vector is zero.
|
||||
#[node_macro::node(category("Math: Vector"))]
|
||||
fn normalize(_: impl Ctx, vector: DVec2) -> DVec2 {
|
||||
vector.normalize_or_zero()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use core_types::Node;
|
||||
use core_types::generic::FnNode;
|
||||
|
||||
#[test]
|
||||
pub fn dot_product_function() {
|
||||
let vector_a = DVec2::new(1., 2.);
|
||||
let vector_b = DVec2::new(3., 4.);
|
||||
assert_eq!(dot_product((), vector_a, vector_b), 11.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn length_function() {
|
||||
let vector = DVec2::new(3., 4.);
|
||||
assert_eq!(length((), vector), 5.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_basic_expression() {
|
||||
let result = math((), 0., "2 + 2".to_string(), 0.);
|
||||
assert_eq!(result, 4.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complex_expression() {
|
||||
let result = math((), 0., "(5 * 3) + (10 / 2)".to_string(), 0.);
|
||||
assert_eq!(result, 20.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_expression() {
|
||||
let result = math((), 0., "0".to_string(), 0.);
|
||||
assert_eq!(result, 0.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_expression() {
|
||||
let result = math((), 0., "invalid".to_string(), 0.);
|
||||
assert_eq!(result, 0.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn foo() {
|
||||
let fnn = FnNode::new(|(a, b)| (b, a));
|
||||
assert_eq!(fnn.eval((1u32, 2u32)), (2, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn add_vectors() {
|
||||
assert_eq!(super::add((), DVec2::ONE, DVec2::ONE), DVec2::ONE * 2.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn subtract_f64() {
|
||||
assert_eq!(super::subtract((), 5_f64, 3_f64), 2.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn divide_vectors() {
|
||||
assert_eq!(super::divide((), DVec2::ONE, 2_f64), DVec2::ONE / 2.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn modulo_positive() {
|
||||
assert_eq!(super::modulo((), -5_f64, 2_f64, true), 1_f64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn modulo_negative() {
|
||||
assert_eq!(super::modulo((), -5_f64, 2_f64, false), -1_f64);
|
||||
}
|
||||
}
|
||||
20
node-graph/nodes/path-bool/Cargo.toml
Normal file
20
node-graph/nodes/path-bool/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "path-bool-nodes"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Path boolean operation nodes for Graphene"
|
||||
authors = ["Graphite Authors <contact@graphite.rs>"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[dependencies]
|
||||
# Local dependencies
|
||||
dyn-any = { workspace = true }
|
||||
core-types = { workspace = true }
|
||||
graphic-types = { workspace = true }
|
||||
node-macro = { workspace = true }
|
||||
glam = { workspace = true }
|
||||
specta = { workspace = true }
|
||||
log = { workspace = true }
|
||||
path-bool = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
vector-types = { workspace = true }
|
||||
443
node-graph/nodes/path-bool/src/lib.rs
Normal file
443
node-graph/nodes/path-bool/src/lib.rs
Normal file
@@ -0,0 +1,443 @@
|
||||
use core_types::table::{Table, TableRow, TableRowRef};
|
||||
use core_types::{Color, Ctx};
|
||||
use dyn_any::DynAny;
|
||||
use glam::{DAffine2, DVec2};
|
||||
use graphic_types::vector_types::subpath::{ManipulatorGroup, PathSegPoints, Subpath, pathseg_points};
|
||||
use graphic_types::vector_types::vector::PointId;
|
||||
use graphic_types::vector_types::vector::algorithms::merge_by_distance::MergeByDistanceExt;
|
||||
use graphic_types::vector_types::vector::style::Fill;
|
||||
use graphic_types::{Graphic, Vector};
|
||||
pub use path_bool as path_bool_lib;
|
||||
use path_bool::{FillRule, PathBooleanOperation};
|
||||
use std::ops::Mul;
|
||||
|
||||
// Import specta so derive macros can find it
|
||||
use core_types::specta;
|
||||
|
||||
// TODO: Fix boolean ops to work by removing .transform() and .one_instnace_*() calls,
|
||||
// TODO: since before we used a Vec of single-row tables and now we use a single table
|
||||
// TODO: with multiple rows while still assuming a single row for the boolean operations.
|
||||
|
||||
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Hash, DynAny, specta::Type, node_macro::ChoiceType)]
|
||||
#[widget(Radio)]
|
||||
pub enum BooleanOperation {
|
||||
#[default]
|
||||
#[icon("BooleanUnion")]
|
||||
Union,
|
||||
#[icon("BooleanSubtractFront")]
|
||||
SubtractFront,
|
||||
#[icon("BooleanSubtractBack")]
|
||||
SubtractBack,
|
||||
#[icon("BooleanIntersect")]
|
||||
Intersect,
|
||||
#[icon("BooleanDifference")]
|
||||
Difference,
|
||||
}
|
||||
|
||||
/// Combines the geometric forms of one or more closed paths into a new vector path that results from cutting or joining the paths by the chosen method.
|
||||
#[node_macro::node(category(""))]
|
||||
async fn boolean_operation<I: graphic_types::IntoGraphicTable + 'n + Send + Clone>(
|
||||
_: impl Ctx,
|
||||
/// The table of vector paths to perform the boolean operation on. Nested tables are automatically flattened.
|
||||
#[implementations(Table<Graphic>, Table<Vector>)]
|
||||
content: I,
|
||||
/// Which boolean operation to perform on the paths.
|
||||
///
|
||||
/// Union combines all paths while cutting out overlapping areas (even the interiors of a single path).
|
||||
/// Subtraction cuts overlapping areas out from the last (Subtract Front) or first (Subtract Back) path.
|
||||
/// Intersection cuts away all but the overlapping areas shared by every path.
|
||||
/// Difference cuts away the overlapping areas shared by every path, leaving only the non-overlapping areas.
|
||||
operation: BooleanOperation,
|
||||
) -> Table<Vector> {
|
||||
let content = content.into_graphic_table();
|
||||
|
||||
// The first index is the bottom of the stack
|
||||
let mut result_vector_table = boolean_operation_on_vector_table(flatten_vector(&content).iter(), operation);
|
||||
|
||||
// Replace the transformation matrix with a mutation of the vector points themselves
|
||||
if let Some(result_vector) = result_vector_table.iter_mut().next() {
|
||||
let transform = *result_vector.transform;
|
||||
*result_vector.transform = DAffine2::IDENTITY;
|
||||
|
||||
Vector::transform(result_vector.element, transform);
|
||||
result_vector.element.style.set_stroke_transform(DAffine2::IDENTITY);
|
||||
result_vector.element.upstream_data = Some(content.clone());
|
||||
|
||||
// Clean up the boolean operation result by merging duplicated points
|
||||
result_vector.element.merge_by_distance_spatial(*result_vector.transform, 0.0001);
|
||||
}
|
||||
|
||||
result_vector_table
|
||||
}
|
||||
|
||||
fn boolean_operation_on_vector_table<'a>(vector: impl DoubleEndedIterator<Item = TableRowRef<'a, Vector>> + Clone, boolean_operation: BooleanOperation) -> Table<Vector> {
|
||||
match boolean_operation {
|
||||
BooleanOperation::Union => union(vector),
|
||||
BooleanOperation::SubtractFront => subtract(vector),
|
||||
BooleanOperation::SubtractBack => subtract(vector.rev()),
|
||||
BooleanOperation::Intersect => intersect(vector),
|
||||
BooleanOperation::Difference => difference(vector),
|
||||
}
|
||||
}
|
||||
|
||||
fn union<'a>(vector: impl DoubleEndedIterator<Item = TableRowRef<'a, Vector>>) -> Table<Vector> {
|
||||
// Reverse the vector table rows so that the result style is the style of the first vector row
|
||||
let mut vector_reversed = vector.rev();
|
||||
|
||||
let mut result_vector_table = Table::new_from_row(vector_reversed.next().map(|x| x.into_cloned()).unwrap_or_default());
|
||||
let mut first_row = result_vector_table.iter_mut().next().expect("Expected the one row we just pushed");
|
||||
|
||||
// Loop over all vector table rows and union it with the result
|
||||
let default = TableRow::default();
|
||||
let mut second_vector = Some(vector_reversed.next().unwrap_or(default.as_ref()));
|
||||
while let Some(lower_vector) = second_vector {
|
||||
let transform_of_lower_into_space_of_upper = first_row.transform.inverse() * *lower_vector.transform;
|
||||
|
||||
let result = &mut first_row.element;
|
||||
|
||||
let upper_path_string = to_path(result, DAffine2::IDENTITY);
|
||||
let lower_path_string = to_path(lower_vector.element, 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_path(&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 = vector_reversed.next();
|
||||
}
|
||||
|
||||
result_vector_table
|
||||
}
|
||||
|
||||
fn subtract<'a>(vector: impl Iterator<Item = TableRowRef<'a, Vector>>) -> Table<Vector> {
|
||||
let mut vector = vector.into_iter();
|
||||
|
||||
let mut result_vector_table = Table::new_from_row(vector.next().map(|x| x.into_cloned()).unwrap_or_default());
|
||||
let mut first_row = result_vector_table.iter_mut().next().expect("Expected the one row we just pushed");
|
||||
let first_row_transform = if first_row.transform.matrix2.determinant() != 0. {
|
||||
first_row.transform.inverse()
|
||||
} else {
|
||||
DAffine2::IDENTITY
|
||||
};
|
||||
|
||||
let mut next_vector = vector.next();
|
||||
|
||||
while let Some(lower_vector) = next_vector {
|
||||
let transform_of_lower_into_space_of_upper = first_row_transform * *lower_vector.transform;
|
||||
|
||||
let result = &mut first_row.element;
|
||||
|
||||
let upper_path_string = to_path(result, DAffine2::IDENTITY);
|
||||
let lower_path_string = to_path(lower_vector.element, 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_path(&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 = vector.next();
|
||||
}
|
||||
|
||||
result_vector_table
|
||||
}
|
||||
|
||||
fn intersect<'a>(vector: impl DoubleEndedIterator<Item = TableRowRef<'a, Vector>>) -> Table<Vector> {
|
||||
let mut vector = vector.rev();
|
||||
|
||||
let mut result_vector_table = Table::new_from_row(vector.next().map(|x| x.into_cloned()).unwrap_or_default());
|
||||
let mut first_row = result_vector_table.iter_mut().next().expect("Expected the one row we just pushed");
|
||||
|
||||
let default = TableRow::default();
|
||||
let mut second_vector = Some(vector.next().unwrap_or(default.as_ref()));
|
||||
|
||||
// For each vector table row, set the result to the intersection of that path and the current result
|
||||
while let Some(lower_vector) = second_vector {
|
||||
let transform_of_lower_into_space_of_upper = first_row.transform.inverse() * *lower_vector.transform;
|
||||
|
||||
let result = &mut first_row.element;
|
||||
|
||||
let upper_path_string = to_path(result, DAffine2::IDENTITY);
|
||||
let lower_path_string = to_path(lower_vector.element, 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_path(&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 = vector.next();
|
||||
}
|
||||
|
||||
result_vector_table
|
||||
}
|
||||
|
||||
fn difference<'a>(vector: impl DoubleEndedIterator<Item = TableRowRef<'a, Vector>> + Clone) -> Table<Vector> {
|
||||
let mut vector_iter = vector.clone().rev();
|
||||
let mut any_intersection = TableRow::default();
|
||||
let default = TableRow::default();
|
||||
let mut second_vector = Some(vector_iter.next().unwrap_or(default.as_ref()));
|
||||
|
||||
// Find where all vector table row paths intersect at least once
|
||||
while let Some(lower_vector) = second_vector {
|
||||
let filtered_vector = vector.clone().filter(|v| *v != lower_vector).collect::<Vec<_>>().into_iter();
|
||||
let unioned = boolean_operation_on_vector_table(filtered_vector, BooleanOperation::Union);
|
||||
let first_row = unioned.iter().next().expect("Expected at least one row after the boolean union");
|
||||
|
||||
let transform_of_lower_into_space_of_upper = first_row.transform.inverse() * *lower_vector.transform;
|
||||
|
||||
let upper_path_string = to_path(first_row.element, DAffine2::IDENTITY);
|
||||
let lower_path_string = to_path(lower_vector.element, 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 element = from_path(&boolean_intersection_string);
|
||||
element.style = first_row.element.style.clone();
|
||||
let boolean_intersection_result = TableRow {
|
||||
element,
|
||||
transform: *first_row.transform,
|
||||
alpha_blending: *first_row.alpha_blending,
|
||||
source_node_id: *first_row.source_node_id,
|
||||
};
|
||||
|
||||
let transform_of_lower_into_space_of_upper = boolean_intersection_result.transform.inverse() * any_intersection.transform;
|
||||
|
||||
let upper_path_string = to_path(&boolean_intersection_result.element, DAffine2::IDENTITY);
|
||||
let lower_path_string = to_path(&any_intersection.element, transform_of_lower_into_space_of_upper);
|
||||
|
||||
#[allow(unused_unsafe)]
|
||||
let union_result = from_path(&unsafe { boolean_union(upper_path_string, lower_path_string) });
|
||||
any_intersection.element = union_result;
|
||||
|
||||
any_intersection.transform = boolean_intersection_result.transform;
|
||||
any_intersection.element.style = boolean_intersection_result.element.style.clone();
|
||||
any_intersection.alpha_blending = boolean_intersection_result.alpha_blending;
|
||||
|
||||
second_vector = vector_iter.next();
|
||||
}
|
||||
|
||||
// Subtract the area where they intersect at least once from the union of all vector paths
|
||||
let union = boolean_operation_on_vector_table(vector, BooleanOperation::Union);
|
||||
boolean_operation_on_vector_table(union.iter().chain(std::iter::once(any_intersection.as_ref())), BooleanOperation::SubtractFront)
|
||||
}
|
||||
|
||||
fn flatten_vector(graphic_table: &Table<Graphic>) -> Table<Vector> {
|
||||
graphic_table
|
||||
.iter()
|
||||
.flat_map(|element| {
|
||||
match element.element.clone() {
|
||||
Graphic::Vector(vector) => {
|
||||
// Apply the parent graphic's transform to each element of the vector table
|
||||
vector
|
||||
.into_iter()
|
||||
.map(|mut sub_vector| {
|
||||
sub_vector.transform = *element.transform * sub_vector.transform;
|
||||
|
||||
sub_vector
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
Graphic::RasterCPU(image) => {
|
||||
let make_row = |transform| {
|
||||
// Convert the image frame into a rectangular subpath with the image's transform
|
||||
let mut subpath = Subpath::new_rect(DVec2::ZERO, DVec2::ONE);
|
||||
subpath.apply_transform(transform);
|
||||
|
||||
// Create a vector table row from the rectangular subpath, with a default black fill
|
||||
let mut element = Vector::from_subpath(subpath);
|
||||
element.style.set_fill(Fill::Solid(Color::BLACK));
|
||||
|
||||
TableRow { element, ..Default::default() }
|
||||
};
|
||||
|
||||
// Apply the parent graphic's transform to each raster element
|
||||
image.iter().map(|row| make_row(*element.transform * *row.transform)).collect::<Vec<_>>()
|
||||
}
|
||||
Graphic::RasterGPU(image) => {
|
||||
let make_row = |transform| {
|
||||
// Convert the image frame into a rectangular subpath with the image's transform
|
||||
let mut subpath = Subpath::new_rect(DVec2::ZERO, DVec2::ONE);
|
||||
subpath.apply_transform(transform);
|
||||
|
||||
// Create a vector table row from the rectangular subpath, with a default black fill
|
||||
let mut element = Vector::from_subpath(subpath);
|
||||
element.style.set_fill(Fill::Solid(Color::BLACK));
|
||||
|
||||
TableRow { element, ..Default::default() }
|
||||
};
|
||||
|
||||
// Apply the parent graphic's transform to each raster element
|
||||
image.iter().map(|row| make_row(*element.transform * *row.transform)).collect::<Vec<_>>()
|
||||
}
|
||||
Graphic::Graphic(mut graphic) => {
|
||||
// Apply the parent graphic's transform to each element of inner table
|
||||
for sub_element in graphic.iter_mut() {
|
||||
*sub_element.transform = *element.transform * *sub_element.transform;
|
||||
}
|
||||
|
||||
// Recursively flatten the inner table into the output vector table
|
||||
let unioned = boolean_operation_on_vector_table(flatten_vector(&graphic).iter(), BooleanOperation::Union);
|
||||
|
||||
unioned.into_iter().collect::<Vec<_>>()
|
||||
}
|
||||
Graphic::Color(color) => color
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let mut element = Vector::default();
|
||||
element.style.set_fill(Fill::Solid(row.element));
|
||||
element.style.set_stroke_transform(DAffine2::IDENTITY);
|
||||
|
||||
TableRow {
|
||||
element,
|
||||
transform: row.transform,
|
||||
alpha_blending: row.alpha_blending,
|
||||
source_node_id: row.source_node_id,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
Graphic::Gradient(gradient) => gradient
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let mut element = Vector::default();
|
||||
element.style.set_fill(Fill::Gradient(graphic_types::vector_types::gradient::Gradient {
|
||||
stops: row.element,
|
||||
..Default::default()
|
||||
}));
|
||||
element.style.set_stroke_transform(DAffine2::IDENTITY);
|
||||
|
||||
TableRow {
|
||||
element,
|
||||
transform: row.transform,
|
||||
alpha_blending: row.alpha_blending,
|
||||
source_node_id: row.source_node_id,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn to_path(vector: &Vector, transform: DAffine2) -> Vec<path_bool::PathSegment> {
|
||||
let mut path = Vec::new();
|
||||
for subpath in vector.stroke_bezier_paths() {
|
||||
to_path_segments(&mut path, &subpath, transform);
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
fn to_path_segments(path: &mut Vec<path_bool::PathSegment>, subpath: &Subpath<PointId>, transform: DAffine2) {
|
||||
use path_bool::PathSegment;
|
||||
let mut global_start = None;
|
||||
let mut global_end = DVec2::ZERO;
|
||||
|
||||
for bezier in subpath.iter() {
|
||||
const EPS: f64 = 1e-8;
|
||||
let transform_point = |pos: DVec2| transform.transform_point2(pos).mul(EPS.recip()).round().mul(EPS);
|
||||
|
||||
let PathSegPoints { p0, p1, p2, p3 } = pathseg_points(bezier);
|
||||
|
||||
let p0 = transform_point(p0);
|
||||
let p1 = p1.map(transform_point);
|
||||
let p2 = p2.map(transform_point);
|
||||
let p3 = transform_point(p3);
|
||||
|
||||
if global_start.is_none() {
|
||||
global_start = Some(p0);
|
||||
}
|
||||
global_end = p3;
|
||||
|
||||
let segment = match (p1, p2) {
|
||||
(None, None) => PathSegment::Line(p0, p3),
|
||||
(None, Some(p2)) | (Some(p2), None) => PathSegment::Quadratic(p0, p2, p3),
|
||||
(Some(p1), Some(p2)) => PathSegment::Cubic(p0, p1, p2, p3),
|
||||
};
|
||||
|
||||
path.push(segment);
|
||||
}
|
||||
if let Some(start) = global_start {
|
||||
path.push(PathSegment::Line(global_end, start));
|
||||
}
|
||||
}
|
||||
|
||||
fn from_path(path_data: &[Path]) -> Vector {
|
||||
const EPSILON: f64 = 1e-5;
|
||||
|
||||
fn is_close(a: DVec2, b: DVec2) -> bool {
|
||||
(a - b).length_squared() < EPSILON * EPSILON
|
||||
}
|
||||
|
||||
let mut all_subpaths = Vec::new();
|
||||
|
||||
for path in path_data.iter().filter(|path| !path.is_empty()) {
|
||||
let cubics: Vec<[DVec2; 4]> = path.iter().map(|segment| segment.to_cubic()).collect();
|
||||
let mut manipulators_list = Vec::new();
|
||||
let mut current_start = None;
|
||||
|
||||
for (index, cubic) in cubics.iter().enumerate() {
|
||||
let [start, handle1, handle2, end] = *cubic;
|
||||
|
||||
if current_start.is_none() || !is_close(start, current_start.unwrap()) {
|
||||
// Start a new subpath
|
||||
if !manipulators_list.is_empty() {
|
||||
all_subpaths.push(Subpath::new(std::mem::take(&mut manipulators_list), true));
|
||||
}
|
||||
// Use the correct in-handle (None) and out-handle for the start point
|
||||
manipulators_list.push(ManipulatorGroup::new(start, None, Some(handle1)));
|
||||
} else {
|
||||
// Update the out-handle of the previous point
|
||||
if let Some(last) = manipulators_list.last_mut() {
|
||||
last.out_handle = Some(handle1);
|
||||
}
|
||||
}
|
||||
|
||||
// Add the end point with the correct in-handle and out-handle (None)
|
||||
manipulators_list.push(ManipulatorGroup::new(end, Some(handle2), None));
|
||||
|
||||
current_start = Some(end);
|
||||
|
||||
// Check if this is the last segment
|
||||
if index == cubics.len() - 1 {
|
||||
all_subpaths.push(Subpath::new(manipulators_list, true));
|
||||
manipulators_list = Vec::new(); // Reset manipulators for the next path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vector::from_subpaths(all_subpaths, false)
|
||||
}
|
||||
|
||||
type Path = Vec<path_bool::PathSegment>;
|
||||
|
||||
fn boolean_union(a: Path, b: Path) -> Vec<Path> {
|
||||
path_bool(a, b, PathBooleanOperation::Union)
|
||||
}
|
||||
|
||||
fn path_bool(a: Path, b: Path, op: PathBooleanOperation) -> Vec<Path> {
|
||||
match path_bool::path_boolean(&a, FillRule::NonZero, &b, FillRule::NonZero, op) {
|
||||
Ok(results) => results,
|
||||
Err(e) => {
|
||||
let a_path = path_bool::path_to_path_data(&a, 0.001);
|
||||
let b_path = path_bool::path_to_path_data(&b, 0.001);
|
||||
log::error!("Boolean error {e:?} encountered while processing {a_path}\n {op:?}\n {b_path}");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn boolean_subtract(a: Path, b: Path) -> Vec<Path> {
|
||||
path_bool(a, b, PathBooleanOperation::Difference)
|
||||
}
|
||||
|
||||
pub fn boolean_intersect(a: Path, b: Path) -> Vec<Path> {
|
||||
path_bool(a, b, PathBooleanOperation::Intersection)
|
||||
}
|
||||
66
node-graph/nodes/raster/Cargo.toml
Normal file
66
node-graph/nodes/raster/Cargo.toml
Normal file
@@ -0,0 +1,66 @@
|
||||
[package]
|
||||
name = "raster-nodes"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Raster operation nodes for Graphene"
|
||||
authors = ["Graphite Authors <contact@graphite.rs>"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = ["std"]
|
||||
shader-nodes = [
|
||||
"std",
|
||||
"dep:raster-nodes-shaders",
|
||||
"dep:wgpu-executor",
|
||||
]
|
||||
std = [
|
||||
"dep:core-types",
|
||||
"dep:dyn-any",
|
||||
"dep:raster-types",
|
||||
"dep:vector-types",
|
||||
"dep:image",
|
||||
"dep:ndarray",
|
||||
"dep:rand",
|
||||
"dep:rand_chacha",
|
||||
"dep:fastnoise-lite",
|
||||
"dep:serde",
|
||||
"dep:specta",
|
||||
"dep:kurbo",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
# Local dependencies
|
||||
no-std-types = { workspace = true }
|
||||
node-macro = { workspace = true }
|
||||
|
||||
# Local std dependencies
|
||||
dyn-any = { workspace = true, optional = true }
|
||||
core-types = { workspace = true, optional = true }
|
||||
raster-types = { workspace = true, optional = true }
|
||||
vector-types = { workspace = true, optional = true }
|
||||
wgpu-executor = { workspace = true, optional = true }
|
||||
raster-nodes-shaders = { path = "./shaders", optional = true }
|
||||
|
||||
# Workspace dependencies
|
||||
bytemuck = { workspace = true }
|
||||
glam = { workspace = true }
|
||||
spirv-std = { workspace = true }
|
||||
num-traits = { workspace = true }
|
||||
num_enum = { workspace = true }
|
||||
|
||||
# Workspace std dependencies
|
||||
specta = { workspace = true, optional = true }
|
||||
image = { workspace = true, optional = true }
|
||||
ndarray = { workspace = true, optional = true }
|
||||
rand = { workspace = true, optional = true }
|
||||
rand_chacha = { workspace = true, optional = true }
|
||||
fastnoise-lite = { workspace = true, optional = true }
|
||||
serde = { workspace = true, optional = true }
|
||||
kurbo = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
17
node-graph/nodes/raster/shaders/Cargo.toml
Normal file
17
node-graph/nodes/raster/shaders/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "raster-nodes-shaders"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "graphene raster data format"
|
||||
authors = ["Graphite Authors <contact@graphite.rs>"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[lib]
|
||||
crate-type = ["rlib", "dylib"]
|
||||
|
||||
[dependencies]
|
||||
|
||||
[build-dependencies]
|
||||
cargo-gpu = { workspace = true }
|
||||
env_logger = { workspace = true }
|
||||
log = { workspace = true }
|
||||
54
node-graph/nodes/raster/shaders/build.rs
Normal file
54
node-graph/nodes/raster/shaders/build.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use cargo_gpu::InstalledBackend;
|
||||
use cargo_gpu::spirv_builder::{MetadataPrintout, SpirvMetadata};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
env_logger::builder().filter_level(log::LevelFilter::Debug).init();
|
||||
|
||||
// Skip building the shader if they are provided externally
|
||||
println!("cargo:rerun-if-env-changed=GRAPHENE_RASTER_NODES_SHADER_PATH");
|
||||
if !std::env::var("GRAPHENE_RASTER_NODES_SHADER_PATH").unwrap_or_default().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Allows overriding the PATH to inject the rust-gpu rust toolchain when building the rest of the project with stable rustc.
|
||||
// Used in nix shell. Do not remove without checking with developers using nix.
|
||||
println!("cargo:rerun-if-env-changed=RUST_GPU_PATH_OVERRIDE");
|
||||
if let Ok(path_override) = std::env::var("RUST_GPU_PATH_OVERRIDE") {
|
||||
let current_path = std::env::var("PATH").unwrap_or_default();
|
||||
let new_path = format!("{path_override}:{current_path}");
|
||||
// SAFETY: Build script is single-threaded therefore this cannot lead to undefined behavior.
|
||||
unsafe {
|
||||
std::env::set_var("PATH", &new_path);
|
||||
}
|
||||
}
|
||||
|
||||
let shader_crate = PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/entrypoint"));
|
||||
|
||||
println!("cargo:rerun-if-env-changed=RUSTC_CODEGEN_SPIRV_PATH");
|
||||
let rustc_codegen_spirv_path = std::env::var("RUSTC_CODEGEN_SPIRV_PATH").unwrap_or_default();
|
||||
let backend = if rustc_codegen_spirv_path.is_empty() {
|
||||
// install the toolchain and build the `rustc_codegen_spirv` codegen backend with it
|
||||
cargo_gpu::Install::from_shader_crate(shader_crate.clone()).run()?
|
||||
} else {
|
||||
// use the `RUSTC_CODEGEN_SPIRV` environment variable to find the codegen backend
|
||||
let mut backend = InstalledBackend::default();
|
||||
backend.rustc_codegen_spirv_location = PathBuf::from(rustc_codegen_spirv_path);
|
||||
backend.toolchain_channel = "nightly".to_string();
|
||||
backend.target_spec_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
backend
|
||||
};
|
||||
|
||||
// build the shader crate
|
||||
let mut builder = backend.to_spirv_builder(shader_crate, "spirv-unknown-naga-wgsl");
|
||||
builder.print_metadata = MetadataPrintout::DependencyOnly;
|
||||
builder.spirv_metadata = SpirvMetadata::Full;
|
||||
let wgsl_result = builder.build()?;
|
||||
let path_to_spv = wgsl_result.module.unwrap_single();
|
||||
|
||||
// needs to be fixed upstream
|
||||
let path_to_wgsl = path_to_spv.with_extension("wgsl");
|
||||
|
||||
println!("cargo::rustc-env=GRAPHENE_RASTER_NODES_SHADER_PATH={}", path_to_wgsl.display());
|
||||
Ok(())
|
||||
}
|
||||
13
node-graph/nodes/raster/shaders/entrypoint/Cargo.toml
Normal file
13
node-graph/nodes/raster/shaders/entrypoint/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "raster-nodes-shaders-entrypoint"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "graphene raster nodes shaders entrypoint"
|
||||
authors = ["Graphite Authors <contact@graphite.rs>"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[lib]
|
||||
crate-type = ["rlib", "dylib"]
|
||||
|
||||
[dependencies]
|
||||
raster-nodes = { path = "../..", default-features = false }
|
||||
2
node-graph/nodes/raster/shaders/entrypoint/src/lib.rs
Normal file
2
node-graph/nodes/raster/shaders/entrypoint/src/lib.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
#![no_std]
|
||||
pub use raster_nodes::*;
|
||||
26
node-graph/nodes/raster/shaders/spirv-unknown-naga-wgsl.json
Normal file
26
node-graph/nodes/raster/shaders/spirv-unknown-naga-wgsl.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"allows-weak-linkage": false,
|
||||
"arch": "spirv",
|
||||
"crt-objects-fallback": "false",
|
||||
"crt-static-allows-dylibs": true,
|
||||
"crt-static-respected": true,
|
||||
"data-layout": "e-m:e-p:32:32:32-i64:64-n8:16:32:64",
|
||||
"dll-prefix": "",
|
||||
"dll-suffix": ".spv.json",
|
||||
"dynamic-linking": true,
|
||||
"emit-debug-gdb-scripts": false,
|
||||
"env": "naga-wgsl",
|
||||
"linker-flavor": "unix",
|
||||
"linker-is-gnu": false,
|
||||
"llvm-target": "spirv-unknown-naga-wgsl",
|
||||
"main-needs-argc-argv": false,
|
||||
"metadata": {
|
||||
"description": null,
|
||||
"host_tools": null,
|
||||
"std": null,
|
||||
"tier": null
|
||||
},
|
||||
"panic-strategy": "abort",
|
||||
"simd-types-indirect": false,
|
||||
"target-pointer-width": "32"
|
||||
}
|
||||
1
node-graph/nodes/raster/shaders/src/lib.rs
Normal file
1
node-graph/nodes/raster/shaders/src/lib.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub const WGSL_SHADER: &str = include_str!(env!("GRAPHENE_RASTER_NODES_SHADER_PATH"));
|
||||
49
node-graph/nodes/raster/src/adjust.rs
Normal file
49
node-graph/nodes/raster/src/adjust.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use no_std_types::color::Color;
|
||||
|
||||
pub trait Adjust<P> {
|
||||
fn adjust(&mut self, map_fn: impl Fn(&P) -> P);
|
||||
}
|
||||
impl Adjust<Color> for Color {
|
||||
fn adjust(&mut self, map_fn: impl Fn(&Color) -> Color) {
|
||||
*self = map_fn(self);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "std")]
|
||||
mod adjust_std {
|
||||
use super::*;
|
||||
use core_types::table::Table;
|
||||
use raster_types::{CPU, Raster};
|
||||
use vector_types::GradientStops;
|
||||
|
||||
impl Adjust<Color> for Table<Raster<CPU>> {
|
||||
fn adjust(&mut self, map_fn: impl Fn(&Color) -> Color) {
|
||||
for row in self.iter_mut() {
|
||||
for color in row.element.data_mut().data.iter_mut() {
|
||||
*color = map_fn(color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Adjust<Color> for Table<Color> {
|
||||
fn adjust(&mut self, map_fn: impl Fn(&Color) -> Color) {
|
||||
for row in self.iter_mut() {
|
||||
*row.element = map_fn(row.element);
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Adjust<Color> for Table<GradientStops> {
|
||||
fn adjust(&mut self, map_fn: impl Fn(&Color) -> Color) {
|
||||
for row in self.iter_mut() {
|
||||
row.element.adjust(&map_fn);
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Adjust<Color> for GradientStops {
|
||||
fn adjust(&mut self, map_fn: impl Fn(&Color) -> Color) {
|
||||
for (_, color) in self.iter_mut() {
|
||||
*color = map_fn(color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1026
node-graph/nodes/raster/src/adjustments.rs
Normal file
1026
node-graph/nodes/raster/src/adjustments.rs
Normal file
File diff suppressed because it is too large
Load Diff
215
node-graph/nodes/raster/src/blending_nodes.rs
Normal file
215
node-graph/nodes/raster/src/blending_nodes.rs
Normal file
@@ -0,0 +1,215 @@
|
||||
use crate::adjust::Adjust;
|
||||
#[cfg(feature = "std")]
|
||||
use core_types::table::Table;
|
||||
use no_std_types::Ctx;
|
||||
use no_std_types::blending::BlendMode;
|
||||
use no_std_types::color::{Color, Pixel};
|
||||
use no_std_types::registry::types::PercentageF32;
|
||||
#[cfg(feature = "std")]
|
||||
use raster_types::{CPU, Raster};
|
||||
#[cfg(feature = "std")]
|
||||
use vector_types::GradientStops;
|
||||
|
||||
pub trait Blend<P: Pixel> {
|
||||
fn blend(&self, under: &Self, blend_fn: impl Fn(P, P) -> P) -> Self;
|
||||
}
|
||||
impl Blend<Color> for Color {
|
||||
fn blend(&self, under: &Self, blend_fn: impl Fn(Color, Color) -> Color) -> Self {
|
||||
blend_fn(*self, *under)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "std")]
|
||||
mod blend_std {
|
||||
use super::*;
|
||||
use core::cmp::Ordering;
|
||||
use core_types::table::Table;
|
||||
use raster_types::Image;
|
||||
use raster_types::Raster;
|
||||
|
||||
impl Blend<Color> for Table<Raster<CPU>> {
|
||||
fn blend(&self, under: &Self, blend_fn: impl Fn(Color, Color) -> Color) -> Self {
|
||||
let mut result_table = self.clone();
|
||||
for (over, under) in result_table.iter_mut().zip(under.iter()) {
|
||||
let data = over.element.data.iter().zip(under.element.data.iter()).map(|(a, b)| blend_fn(*a, *b)).collect();
|
||||
|
||||
*over.element = Raster::new_cpu(Image {
|
||||
data,
|
||||
width: over.element.width,
|
||||
height: over.element.height,
|
||||
base64_string: None,
|
||||
});
|
||||
}
|
||||
result_table
|
||||
}
|
||||
}
|
||||
impl Blend<Color> for Table<Color> {
|
||||
fn blend(&self, under: &Self, blend_fn: impl Fn(Color, Color) -> Color) -> Self {
|
||||
let mut result_table = self.clone();
|
||||
for (over, under) in result_table.iter_mut().zip(under.iter()) {
|
||||
*over.element = blend_fn(*over.element, *under.element);
|
||||
}
|
||||
result_table
|
||||
}
|
||||
}
|
||||
impl Blend<Color> for Table<GradientStops> {
|
||||
fn blend(&self, under: &Self, blend_fn: impl Fn(Color, Color) -> Color) -> Self {
|
||||
let mut result_table = self.clone();
|
||||
for (over, under) in result_table.iter_mut().zip(under.iter()) {
|
||||
*over.element = over.element.blend(under.element, &blend_fn);
|
||||
}
|
||||
result_table
|
||||
}
|
||||
}
|
||||
impl Blend<Color> for GradientStops {
|
||||
fn blend(&self, under: &Self, blend_fn: impl Fn(Color, Color) -> Color) -> Self {
|
||||
let mut combined_stops = self.iter().map(|(position, _)| position).chain(under.iter().map(|(position, _)| position)).collect::<Vec<_>>();
|
||||
combined_stops.dedup_by(|&mut a, &mut b| (a - b).abs() < 1e-6);
|
||||
combined_stops.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
|
||||
let stops = combined_stops
|
||||
.into_iter()
|
||||
.map(|&position| {
|
||||
let over_color = self.evaluate(position);
|
||||
let under_color = under.evaluate(position);
|
||||
let color = blend_fn(over_color, under_color);
|
||||
(position, color)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
GradientStops::new(stops)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn blend_colors(foreground: Color, background: Color, blend_mode: BlendMode, opacity: f32) -> Color {
|
||||
let target_color = match blend_mode {
|
||||
// Other utility blend modes (hidden from the normal list) - do not have alpha blend
|
||||
BlendMode::Erase => return background.alpha_subtract(foreground),
|
||||
BlendMode::Restore => return background.alpha_add(foreground),
|
||||
BlendMode::MultiplyAlpha => return background.alpha_multiply(foreground),
|
||||
blend_mode => apply_blend_mode(foreground, background, blend_mode),
|
||||
};
|
||||
|
||||
background.alpha_blend(target_color.to_associated_alpha(opacity))
|
||||
}
|
||||
|
||||
pub fn apply_blend_mode(foreground: Color, background: Color, blend_mode: BlendMode) -> Color {
|
||||
match blend_mode {
|
||||
// Normal group
|
||||
BlendMode::Normal => background.blend_rgb(foreground, Color::blend_normal),
|
||||
// Darken group
|
||||
BlendMode::Darken => background.blend_rgb(foreground, Color::blend_darken),
|
||||
BlendMode::Multiply => background.blend_rgb(foreground, Color::blend_multiply),
|
||||
BlendMode::ColorBurn => background.blend_rgb(foreground, Color::blend_color_burn),
|
||||
BlendMode::LinearBurn => background.blend_rgb(foreground, Color::blend_linear_burn),
|
||||
BlendMode::DarkerColor => background.blend_darker_color(foreground),
|
||||
// Lighten group
|
||||
BlendMode::Lighten => background.blend_rgb(foreground, Color::blend_lighten),
|
||||
BlendMode::Screen => background.blend_rgb(foreground, Color::blend_screen),
|
||||
BlendMode::ColorDodge => background.blend_rgb(foreground, Color::blend_color_dodge),
|
||||
BlendMode::LinearDodge => background.blend_rgb(foreground, Color::blend_linear_dodge),
|
||||
BlendMode::LighterColor => background.blend_lighter_color(foreground),
|
||||
// Contrast group
|
||||
BlendMode::Overlay => foreground.blend_rgb(background, Color::blend_hardlight),
|
||||
BlendMode::SoftLight => background.blend_rgb(foreground, Color::blend_softlight),
|
||||
BlendMode::HardLight => background.blend_rgb(foreground, Color::blend_hardlight),
|
||||
BlendMode::VividLight => background.blend_rgb(foreground, Color::blend_vivid_light),
|
||||
BlendMode::LinearLight => background.blend_rgb(foreground, Color::blend_linear_light),
|
||||
BlendMode::PinLight => background.blend_rgb(foreground, Color::blend_pin_light),
|
||||
BlendMode::HardMix => background.blend_rgb(foreground, Color::blend_hard_mix),
|
||||
// Inversion group
|
||||
BlendMode::Difference => background.blend_rgb(foreground, Color::blend_difference),
|
||||
BlendMode::Exclusion => background.blend_rgb(foreground, Color::blend_exclusion),
|
||||
BlendMode::Subtract => background.blend_rgb(foreground, Color::blend_subtract),
|
||||
BlendMode::Divide => background.blend_rgb(foreground, Color::blend_divide),
|
||||
// Component group
|
||||
BlendMode::Hue => background.blend_hue(foreground),
|
||||
BlendMode::Saturation => background.blend_saturation(foreground),
|
||||
BlendMode::Color => background.blend_color(foreground),
|
||||
BlendMode::Luminosity => background.blend_luminosity(foreground),
|
||||
// Other utility blend modes (hidden from the normal list) - do not have alpha blend
|
||||
_ => panic!("Used blend mode without alpha blend"),
|
||||
}
|
||||
}
|
||||
|
||||
#[node_macro::node(category("Raster"), cfg(feature = "std"))]
|
||||
fn blend<T: Blend<Color> + Send>(
|
||||
_: impl Ctx,
|
||||
#[implementations(
|
||||
Table<Raster<CPU>>,
|
||||
Table<Color>,
|
||||
Table<GradientStops>,
|
||||
GradientStops,
|
||||
)]
|
||||
#[gpu_image]
|
||||
over: T,
|
||||
#[expose]
|
||||
#[implementations(
|
||||
Table<Raster<CPU>>,
|
||||
Table<Color>,
|
||||
Table<GradientStops>,
|
||||
GradientStops,
|
||||
)]
|
||||
#[gpu_image]
|
||||
under: T,
|
||||
blend_mode: BlendMode,
|
||||
#[default(100.)] opacity: PercentageF32,
|
||||
) -> T {
|
||||
over.blend(&under, |a, b| blend_colors(a, b, blend_mode, opacity / 100.))
|
||||
}
|
||||
|
||||
#[node_macro::node(category("Raster: Adjustment"), shader_node(PerPixelAdjust))]
|
||||
fn color_overlay<T: Adjust<Color>>(
|
||||
_: impl Ctx,
|
||||
#[implementations(
|
||||
Table<Raster<CPU>>,
|
||||
Table<Color>,
|
||||
Table<GradientStops>,
|
||||
GradientStops,
|
||||
)]
|
||||
#[gpu_image]
|
||||
mut image: T,
|
||||
#[default(Color::BLACK)] color: Color,
|
||||
blend_mode: BlendMode,
|
||||
#[default(100.)] opacity: PercentageF32,
|
||||
) -> T {
|
||||
let opacity = (opacity / 100.).clamp(0., 1.);
|
||||
|
||||
image.adjust(|pixel| {
|
||||
let image = pixel.map_rgb(|channel| channel * (1. - opacity));
|
||||
|
||||
// The apply blend mode function divides rgb by the alpha channel for the background. This undoes that.
|
||||
let associated_pixel = Color::from_rgbaf32_unchecked(pixel.r() * pixel.a(), pixel.g() * pixel.a(), pixel.b() * pixel.a(), pixel.a());
|
||||
let overlay = apply_blend_mode(color, associated_pixel, blend_mode).map_rgb(|channel| channel * opacity);
|
||||
|
||||
Color::from_rgbaf32_unchecked(image.r() + overlay.r(), image.g() + overlay.g(), image.b() + overlay.b(), pixel.a())
|
||||
});
|
||||
image
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "std", test))]
|
||||
mod test {
|
||||
use core_types::blending::BlendMode;
|
||||
use core_types::color::Color;
|
||||
use core_types::table::Table;
|
||||
use raster_types::Image;
|
||||
use raster_types::Raster;
|
||||
|
||||
#[tokio::test]
|
||||
async fn color_overlay_multiply() {
|
||||
let image_color = Color::from_rgbaf32_unchecked(0.7, 0.6, 0.5, 0.4);
|
||||
let image = Image::new(1, 1, image_color);
|
||||
|
||||
// Color { red: 0., green: 1., blue: 0., alpha: 1. }
|
||||
let overlay_color = Color::GREEN;
|
||||
|
||||
// 100% of the output should come from the multiplied value
|
||||
let opacity = 100.;
|
||||
|
||||
let result = super::color_overlay((), Table::new_from_element(Raster::new_cpu(image.clone())), overlay_color, BlendMode::Multiply, opacity);
|
||||
let result = result.iter().next().unwrap().element;
|
||||
|
||||
// The output should just be the original green and alpha channels (as we multiply them by 1 and other channels by 0)
|
||||
assert_eq!(result.data[0], Color::from_rgbaf32_unchecked(0., image_color.g(), 0., image_color.a()));
|
||||
}
|
||||
}
|
||||
123
node-graph/nodes/raster/src/cubic_spline.rs
Normal file
123
node-graph/nodes/raster/src/cubic_spline.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
#[derive(Debug)]
|
||||
pub struct CubicSplines {
|
||||
pub x: [f32; 4],
|
||||
pub y: [f32; 4],
|
||||
}
|
||||
|
||||
impl CubicSplines {
|
||||
pub fn solve(&self) -> [f32; 4] {
|
||||
let (x, y) = (&self.x, &self.y);
|
||||
|
||||
// Build an augmented matrix to solve the system of equations using Gaussian elimination
|
||||
let mut augmented_matrix = [
|
||||
[
|
||||
2. / (x[1] - x[0]),
|
||||
1. / (x[1] - x[0]),
|
||||
0.,
|
||||
0.,
|
||||
// |
|
||||
3. * (y[1] - y[0]) / ((x[1] - x[0]) * (x[1] - x[0])),
|
||||
],
|
||||
[
|
||||
1. / (x[1] - x[0]),
|
||||
2. * (1. / (x[1] - x[0]) + 1. / (x[2] - x[1])),
|
||||
1. / (x[2] - x[1]),
|
||||
0.,
|
||||
// |
|
||||
3. * ((y[1] - y[0]) / ((x[1] - x[0]) * (x[1] - x[0])) + (y[2] - y[1]) / ((x[2] - x[1]) * (x[2] - x[1]))),
|
||||
],
|
||||
[
|
||||
0.,
|
||||
1. / (x[2] - x[1]),
|
||||
2. * (1. / (x[2] - x[1]) + 1. / (x[3] - x[2])),
|
||||
1. / (x[3] - x[2]),
|
||||
// |
|
||||
3. * ((y[2] - y[1]) / ((x[2] - x[1]) * (x[2] - x[1])) + (y[3] - y[2]) / ((x[3] - x[2]) * (x[3] - x[2]))),
|
||||
],
|
||||
[
|
||||
0.,
|
||||
0.,
|
||||
1. / (x[3] - x[2]),
|
||||
2. / (x[3] - x[2]),
|
||||
// |
|
||||
3. * (y[3] - y[2]) / ((x[3] - x[2]) * (x[3] - x[2])),
|
||||
],
|
||||
];
|
||||
|
||||
// Gaussian elimination: forward elimination
|
||||
for row in 0..4 {
|
||||
let pivot_row_index = (row..4)
|
||||
.max_by(|&a_row, &b_row| {
|
||||
augmented_matrix[a_row][row]
|
||||
.abs()
|
||||
.partial_cmp(&augmented_matrix[b_row][row].abs())
|
||||
.unwrap_or(core::cmp::Ordering::Equal)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Swap the current row with the row that has the largest pivot element
|
||||
augmented_matrix.swap(row, pivot_row_index);
|
||||
|
||||
// Eliminate the current column in all rows below the current one
|
||||
for row_below_current in row + 1..4 {
|
||||
assert!(augmented_matrix[row][row].abs() > f32::EPSILON);
|
||||
|
||||
let scale_factor = augmented_matrix[row_below_current][row] / augmented_matrix[row][row];
|
||||
for col in row..5 {
|
||||
augmented_matrix[row_below_current][col] -= augmented_matrix[row][col] * scale_factor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gaussian elimination: back substitution
|
||||
let mut solutions = [0.; 4];
|
||||
for col in (0..4).rev() {
|
||||
assert!(augmented_matrix[col][col].abs() > f32::EPSILON);
|
||||
|
||||
solutions[col] = augmented_matrix[col][4] / augmented_matrix[col][col];
|
||||
|
||||
for row in (0..col).rev() {
|
||||
augmented_matrix[row][4] -= augmented_matrix[row][col] * solutions[col];
|
||||
augmented_matrix[row][col] = 0.;
|
||||
}
|
||||
}
|
||||
|
||||
solutions
|
||||
}
|
||||
|
||||
pub fn interpolate(&self, input: f32, solutions: &[f32]) -> f32 {
|
||||
if input <= self.x[0] {
|
||||
return self.y[0];
|
||||
}
|
||||
if input >= self.x[self.x.len() - 1] {
|
||||
return self.y[self.x.len() - 1];
|
||||
}
|
||||
|
||||
// Find the segment that the input falls between
|
||||
let mut segment = 1;
|
||||
while self.x[segment] < input {
|
||||
segment += 1;
|
||||
}
|
||||
let segment_start = segment - 1;
|
||||
let segment_end = segment;
|
||||
|
||||
// Calculate the output value using quadratic interpolation
|
||||
let input_value = self.x[segment_start];
|
||||
let input_value_prev = self.x[segment_end];
|
||||
let output_value = self.y[segment_start];
|
||||
let output_value_prev = self.y[segment_end];
|
||||
let solutions_value = solutions[segment_start];
|
||||
let solutions_value_prev = solutions[segment_end];
|
||||
|
||||
let output_delta = solutions_value_prev * (input_value - input_value_prev) - (output_value - output_value_prev);
|
||||
let solution_delta = (output_value - output_value_prev) - solutions_value * (input_value - input_value_prev);
|
||||
|
||||
let input_ratio = (input - input_value_prev) / (input_value - input_value_prev);
|
||||
let prev_output_ratio = (1. - input_ratio) * output_value_prev;
|
||||
let output_ratio = input_ratio * output_value;
|
||||
let quadratic_ratio = input_ratio * (1. - input_ratio) * (output_delta * (1. - input_ratio) + solution_delta * input_ratio);
|
||||
|
||||
let result = prev_output_ratio + output_ratio + quadratic_ratio;
|
||||
result.clamp(0., 1.)
|
||||
}
|
||||
}
|
||||
81
node-graph/nodes/raster/src/curve.rs
Normal file
81
node-graph/nodes/raster/src/curve.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use core_types::Node;
|
||||
use core_types::color::{Channel, Linear, LuminanceMut};
|
||||
use dyn_any::{DynAny, StaticType, StaticTypeSized};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::ops::{Add, Mul, Sub};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, DynAny, specta::Type, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Curve {
|
||||
#[serde(rename = "manipulatorGroups")]
|
||||
pub manipulator_groups: Vec<CurveManipulatorGroup>,
|
||||
#[serde(rename = "firstHandle")]
|
||||
pub first_handle: [f32; 2],
|
||||
#[serde(rename = "lastHandle")]
|
||||
pub last_handle: [f32; 2],
|
||||
}
|
||||
|
||||
impl Default for Curve {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
manipulator_groups: vec![],
|
||||
first_handle: [0.2; 2],
|
||||
last_handle: [0.8; 2],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for Curve {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.manipulator_groups.hash(state);
|
||||
[self.first_handle, self.last_handle].iter().flatten().for_each(|f| f.to_bits().hash(state));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, DynAny, specta::Type, serde::Serialize, serde::Deserialize)]
|
||||
pub struct CurveManipulatorGroup {
|
||||
pub anchor: [f32; 2],
|
||||
pub handles: [[f32; 2]; 2],
|
||||
}
|
||||
|
||||
impl Hash for CurveManipulatorGroup {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
for c in self.handles.iter().chain([&self.anchor]).flatten() {
|
||||
c.to_bits().hash(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ValueMapperNode<C> {
|
||||
lut: Vec<C>,
|
||||
}
|
||||
|
||||
unsafe impl<C: StaticTypeSized> StaticType for ValueMapperNode<C> {
|
||||
type Static = ValueMapperNode<C::Static>;
|
||||
}
|
||||
|
||||
impl<C> ValueMapperNode<C> {
|
||||
pub const fn new(lut: Vec<C>) -> Self {
|
||||
Self { lut }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'i, L: LuminanceMut + 'i> Node<'i, L> for ValueMapperNode<L::LuminanceChannel>
|
||||
where
|
||||
L::LuminanceChannel: Linear + Copy,
|
||||
L::LuminanceChannel: Add<Output = L::LuminanceChannel>,
|
||||
L::LuminanceChannel: Sub<Output = L::LuminanceChannel>,
|
||||
L::LuminanceChannel: Mul<Output = L::LuminanceChannel>,
|
||||
{
|
||||
type Output = L;
|
||||
|
||||
fn eval(&'i self, mut val: L) -> L {
|
||||
let luminance: f32 = val.luminance().to_linear();
|
||||
let floating_sample_index = luminance * (self.lut.len() - 1) as f32;
|
||||
let index_in_lut = floating_sample_index.floor() as usize;
|
||||
let a = self.lut[index_in_lut];
|
||||
let b = self.lut[(index_in_lut + 1).clamp(0, self.lut.len() - 1)];
|
||||
let result = a.lerp(b, L::LuminanceChannel::from_linear(floating_sample_index.fract()));
|
||||
val.set_luminance(result);
|
||||
val
|
||||
}
|
||||
}
|
||||
265
node-graph/nodes/raster/src/dehaze.rs
Normal file
265
node-graph/nodes/raster/src/dehaze.rs
Normal file
@@ -0,0 +1,265 @@
|
||||
use core_types::context::Ctx;
|
||||
use core_types::registry::types::Percentage;
|
||||
use core_types::table::Table;
|
||||
use image::{DynamicImage, GenericImage, GenericImageView, GrayImage, ImageBuffer, Luma, Rgba, RgbaImage};
|
||||
use ndarray::{Array2, ArrayBase, Dim, OwnedRepr};
|
||||
use raster_types::Image;
|
||||
use raster_types::{CPU, Raster};
|
||||
use std::cmp::{max, min};
|
||||
|
||||
#[node_macro::node(category("Raster: Filter"))]
|
||||
async fn dehaze(_: impl Ctx, image_frame: Table<Raster<CPU>>, strength: Percentage) -> Table<Raster<CPU>> {
|
||||
image_frame
|
||||
.into_iter()
|
||||
.map(|mut row| {
|
||||
let image = row.element;
|
||||
// Prepare the image data for processing
|
||||
let image_data = bytemuck::cast_vec(image.data.clone());
|
||||
let image_buffer = image::Rgba32FImage::from_raw(image.width, image.height, image_data).expect("Failed to convert internal image format into image-rs data type.");
|
||||
let dynamic_image: DynamicImage = image_buffer.into();
|
||||
|
||||
// Run the dehaze algorithm
|
||||
let dehazed_dynamic_image = dehaze_image(dynamic_image, strength / 100.);
|
||||
|
||||
// Prepare the image data for returning
|
||||
let buffer = dehazed_dynamic_image.to_rgba32f().into_raw();
|
||||
let color_vec = bytemuck::cast_vec(buffer);
|
||||
let dehazed_image = Image {
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
data: color_vec,
|
||||
base64_string: None,
|
||||
};
|
||||
|
||||
row.element = Raster::new_cpu(dehazed_image);
|
||||
row
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// There is no real point in modifying these values because they do not change the final result all that much.
|
||||
// The authors of the paper recommended using these values to get a reasonable balance of performance and quality.
|
||||
const PATCH_SIZE: u32 = 15;
|
||||
const TOP_PERCENT: f64 = 0.001;
|
||||
const RADIUS: u32 = 60;
|
||||
const EPSILON: f64 = 0.0001;
|
||||
const TX: f32 = 0.1;
|
||||
|
||||
// Dehazing algorithm based on "Single Image Haze Removal Using Dark Channel Prior"
|
||||
// Paper: <https://www.researchgate.net/publication/220182411_Single_Image_Haze_Removal_Using_Dark_Channel_Prior>
|
||||
// TODO: Make this algorithm work with negative strength values
|
||||
fn dehaze_image(image: DynamicImage, strength: f64) -> DynamicImage {
|
||||
// TODO: Break out this pair of steps into its own node, with a memoize node which caches the pair of outputs, so the strength can be adjusted without recomputing these two steps.
|
||||
let dark_channel = compute_dark_channel(&image);
|
||||
let atmospheric_light = estimate_atmospheric_light(&image, &dark_channel);
|
||||
|
||||
let transmission_map = estimate_transmission_map(&image, &dark_channel, strength);
|
||||
let refined_transmission_map = refine_transmission_map(&image, &transmission_map);
|
||||
|
||||
recover(&image, &refined_transmission_map, atmospheric_light)
|
||||
}
|
||||
|
||||
fn compute_dark_channel(image: &DynamicImage) -> DynamicImage {
|
||||
let (width, height) = image.dimensions();
|
||||
let mut dark_channel = GrayImage::new(width, height);
|
||||
let half_patch = PATCH_SIZE / 2;
|
||||
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let pixel = image.get_pixel(x, y);
|
||||
let min_intensity = min(min(pixel[0], pixel[1]), pixel[2]);
|
||||
dark_channel.put_pixel(x, y, Luma([min_intensity]));
|
||||
}
|
||||
}
|
||||
|
||||
let mut eroded_channel = RgbaImage::new(width, height);
|
||||
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let mut local_min = u8::MAX;
|
||||
|
||||
for dy in 0..PATCH_SIZE {
|
||||
for dx in 0..PATCH_SIZE {
|
||||
let nx = x as i32 + dx as i32 - half_patch as i32;
|
||||
let ny = y as i32 + dy as i32 - half_patch as i32;
|
||||
|
||||
if nx >= 0 && nx < width as i32 && ny >= 0 && ny < height as i32 {
|
||||
let intensity = dark_channel.get_pixel(nx as u32, ny as u32)[0];
|
||||
if intensity < local_min {
|
||||
local_min = intensity;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let alpha = image.get_pixel(x, y)[3];
|
||||
eroded_channel.put_pixel(x, y, Rgba([local_min, local_min, local_min, alpha]));
|
||||
}
|
||||
}
|
||||
|
||||
DynamicImage::ImageRgba8(eroded_channel)
|
||||
}
|
||||
|
||||
fn estimate_atmospheric_light(hazy: &DynamicImage, dark_channel: &DynamicImage) -> Rgba<u8> {
|
||||
let (width, height) = hazy.dimensions();
|
||||
let dark = dark_channel.to_luma_alpha8();
|
||||
let total_pixels = (width * height) as usize;
|
||||
let num_pixels = ((TOP_PERCENT / 100.) * total_pixels as f64).ceil() as usize;
|
||||
|
||||
let mut intensities: Vec<(u32, u32, f64)> = Vec::with_capacity(total_pixels);
|
||||
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let pixel = dark.get_pixel(x, y);
|
||||
let intensity = pixel.0[0] as f64;
|
||||
intensities.push((x, y, intensity))
|
||||
}
|
||||
}
|
||||
|
||||
intensities.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap());
|
||||
|
||||
let top_intensities = &intensities[..num_pixels];
|
||||
|
||||
let mut atm_sum = [0., 0., 0.];
|
||||
for (x, y, _) in top_intensities {
|
||||
let pixel = hazy.get_pixel(*x, *y);
|
||||
atm_sum[0] += pixel[0] as f64;
|
||||
atm_sum[1] += pixel[1] as f64;
|
||||
atm_sum[2] += pixel[2] as f64;
|
||||
}
|
||||
|
||||
let num_pixels = num_pixels as f64;
|
||||
|
||||
Rgba([(atm_sum[0] / num_pixels) as u8, (atm_sum[1] / num_pixels) as u8, (atm_sum[2] / num_pixels) as u8, 255])
|
||||
}
|
||||
|
||||
fn estimate_transmission_map(image: &DynamicImage, dark_channel: &DynamicImage, omega: f64) -> DynamicImage {
|
||||
let (width, height) = image.dimensions();
|
||||
let mut transmission_map = RgbaImage::new(width, height);
|
||||
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let min_intensity = dark_channel.get_pixel(x, y).0[0] as f32 / 255.;
|
||||
let transmission_value = 1. - omega * min_intensity as f64;
|
||||
let alpha = image.get_pixel(x, y)[3];
|
||||
transmission_map.put_pixel(
|
||||
x,
|
||||
y,
|
||||
Rgba([(transmission_value * 255.) as u8, (transmission_value * 255.) as u8, (transmission_value * 255.) as u8, alpha]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DynamicImage::ImageRgba8(transmission_map)
|
||||
}
|
||||
|
||||
fn refine_transmission_map(img: &DynamicImage, transmission_map: &DynamicImage) -> DynamicImage {
|
||||
let gray_image = img.to_luma8();
|
||||
|
||||
let normalized_gray_image: GrayImage = ImageBuffer::from_fn(gray_image.width(), gray_image.height(), |x, y| {
|
||||
let pixel = gray_image.get_pixel(x, y);
|
||||
let normalized_value = (pixel[0] as f64 / 255.) * 255.;
|
||||
Luma([normalized_value as u8])
|
||||
});
|
||||
|
||||
let normalized_gray_image = DynamicImage::ImageLuma8(normalized_gray_image);
|
||||
|
||||
guided_filter(&normalized_gray_image, transmission_map, RADIUS, EPSILON)
|
||||
}
|
||||
|
||||
fn recover(im: &DynamicImage, t: &DynamicImage, a: Rgba<u8>) -> DynamicImage {
|
||||
let (width, height) = im.dimensions();
|
||||
let mut res = DynamicImage::new_rgba8(width, height);
|
||||
|
||||
let a = [a[0] as f32 / 255., a[1] as f32 / 255., a[2] as f32 / 255.];
|
||||
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let im_pixel = im.get_pixel(x, y).0;
|
||||
let t_pixel = t.get_pixel(x, y).0;
|
||||
let t_val = f32::max(t_pixel[0] as f32 / 255., TX);
|
||||
|
||||
let mut res_pixel = [0; 4];
|
||||
for ind in 0..3 {
|
||||
res_pixel[ind] = ((((im_pixel[ind] as f32 / 255. - a[ind]) / t_val) + a[ind]).clamp(0., 1.) * 255.) as u8;
|
||||
}
|
||||
res_pixel[3] = im_pixel[3];
|
||||
|
||||
res.put_pixel(x, y, Rgba(res_pixel));
|
||||
}
|
||||
}
|
||||
|
||||
res
|
||||
}
|
||||
|
||||
fn guided_filter(guidance_img: &DynamicImage, input_img: &DynamicImage, r: u32, epsilon: f64) -> DynamicImage {
|
||||
let (width, height) = guidance_img.dimensions();
|
||||
let radius = r as i32;
|
||||
|
||||
let guidance_nd = image_to_ndarray(guidance_img);
|
||||
let input_nd = image_to_ndarray(input_img);
|
||||
|
||||
let mean_guidance = box_filter(&guidance_nd, radius);
|
||||
let mean_input = box_filter(&input_nd, radius);
|
||||
let corr_guidance = box_filter(&(guidance_nd.clone() * guidance_nd.clone()), radius);
|
||||
let corr_guidance_input = box_filter(&(guidance_nd.clone() * input_nd.clone()), radius);
|
||||
|
||||
let var_guidance = &corr_guidance - &(mean_guidance.clone() * mean_guidance.clone());
|
||||
let cov_guidance_input = &corr_guidance_input - &(mean_guidance.clone() * mean_input.clone());
|
||||
|
||||
let a = &cov_guidance_input / &(var_guidance.clone() + epsilon);
|
||||
let b = mean_input - &(a.clone() * mean_guidance);
|
||||
|
||||
let mean_a = box_filter(&a, radius);
|
||||
let mean_b = box_filter(&b, radius);
|
||||
|
||||
let q = &mean_a * &guidance_nd + mean_b;
|
||||
|
||||
ndarray_to_image(&q, width, height)
|
||||
}
|
||||
|
||||
fn box_filter(img: &Array2<f64>, radius: i32) -> Array2<f64> {
|
||||
let (height, width) = img.dim();
|
||||
let mut result = Array2::zeros((height, width));
|
||||
let mut integral_image: ArrayBase<OwnedRepr<f64>, Dim<[usize; 2]>> = Array2::zeros((height + 1, width + 1));
|
||||
|
||||
// Compute integral image
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
integral_image[(y + 1, x + 1)] = img[(y, x)] + integral_image[(y, x + 1)] + integral_image[(y + 1, x)] - integral_image[(y, x)];
|
||||
}
|
||||
}
|
||||
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let y1 = max(0, y as i32 - radius) as usize;
|
||||
let y2 = min(height as i32 - 1, y as i32 + radius) as usize;
|
||||
let x1 = max(0, x as i32 - radius) as usize;
|
||||
let x2 = min(width as i32 - 1, x as i32 + radius) as usize;
|
||||
|
||||
let area = (y2 - y1 + 1) as f64 * (x2 - x1 + 1) as f64;
|
||||
|
||||
result[(y, x)] = (integral_image[(y2 + 1, x2 + 1)] - integral_image[(y1, x2 + 1)] - integral_image[(y2 + 1, x1)] + integral_image[(y1, x1)]) / area;
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn image_to_ndarray(img: &DynamicImage) -> Array2<f64> {
|
||||
let (width, height) = img.dimensions();
|
||||
let mut array = Array2::zeros((height as usize, width as usize));
|
||||
for (x, y, pixel) in img.pixels() {
|
||||
let luminance = pixel.0[0] as f64 / 255.;
|
||||
array[(y as usize, x as usize)] = luminance;
|
||||
}
|
||||
array
|
||||
}
|
||||
|
||||
fn ndarray_to_image(array: &Array2<f64>, width: u32, height: u32) -> DynamicImage {
|
||||
let mut img = DynamicImage::new_rgba8(width, height);
|
||||
for ((y, x), &value) in array.indexed_iter() {
|
||||
let clamped_value = (value * 255.).clamp(0., 255.) as u8;
|
||||
img.put_pixel(x as u32, y as u32, Rgba([clamped_value, clamped_value, clamped_value, 255]));
|
||||
}
|
||||
img
|
||||
}
|
||||
181
node-graph/nodes/raster/src/filter.rs
Normal file
181
node-graph/nodes/raster/src/filter.rs
Normal file
@@ -0,0 +1,181 @@
|
||||
use core_types::color::Color;
|
||||
use core_types::context::Ctx;
|
||||
use core_types::registry::types::PixelLength;
|
||||
use core_types::table::Table;
|
||||
use raster_types::Image;
|
||||
use raster_types::{Bitmap, BitmapMut};
|
||||
use raster_types::{CPU, Raster};
|
||||
|
||||
/// Blurs the image with a Gaussian or blur kernel filter.
|
||||
#[node_macro::node(category("Raster: Filter"))]
|
||||
async fn blur(
|
||||
_: impl Ctx,
|
||||
/// The image to be blurred.
|
||||
image_frame: Table<Raster<CPU>>,
|
||||
/// The radius of the blur kernel.
|
||||
#[range((0., 100.))]
|
||||
#[hard_min(0.)]
|
||||
radius: PixelLength,
|
||||
/// Use a lower-quality box kernel instead of a circular Gaussian kernel. This is faster but produces boxy artifacts.
|
||||
box_blur: bool,
|
||||
/// Opt to incorrectly apply the filter with color calculations in gamma space for compatibility with the results from other software.
|
||||
gamma: bool,
|
||||
) -> Table<Raster<CPU>> {
|
||||
image_frame
|
||||
.into_iter()
|
||||
.map(|mut row| {
|
||||
let image = row.element.clone();
|
||||
|
||||
// Run blur algorithm
|
||||
let blurred_image = if radius < 0.1 {
|
||||
// Minimum blur radius
|
||||
image.clone()
|
||||
} else if box_blur {
|
||||
Raster::new_cpu(box_blur_algorithm(image.into_data(), radius, gamma))
|
||||
} else {
|
||||
Raster::new_cpu(gaussian_blur_algorithm(image.into_data(), radius, gamma))
|
||||
};
|
||||
|
||||
row.element = blurred_image;
|
||||
row
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// 1D gaussian kernel
|
||||
fn gaussian_kernel(radius: f64) -> Vec<f64> {
|
||||
// Given radius, compute the size of the kernel that's approximately three times the radius
|
||||
let kernel_radius = (3. * radius).ceil() as usize;
|
||||
let kernel_size = 2 * kernel_radius + 1;
|
||||
let mut gaussian_kernel: Vec<f64> = vec![0.; kernel_size];
|
||||
|
||||
// Kernel values
|
||||
let two_radius_squared = 2. * radius * radius;
|
||||
let sum = gaussian_kernel
|
||||
.iter_mut()
|
||||
.enumerate()
|
||||
.map(|(i, value_at_index)| {
|
||||
let x = i as f64 - kernel_radius as f64;
|
||||
let exponent = -(x * x) / two_radius_squared;
|
||||
*value_at_index = exponent.exp();
|
||||
*value_at_index
|
||||
})
|
||||
.sum::<f64>();
|
||||
|
||||
// Normalize
|
||||
gaussian_kernel.iter_mut().for_each(|value_at_index| *value_at_index /= sum);
|
||||
|
||||
gaussian_kernel
|
||||
}
|
||||
|
||||
fn gaussian_blur_algorithm(mut original_buffer: Image<Color>, radius: f64, gamma: bool) -> Image<Color> {
|
||||
if gamma {
|
||||
original_buffer.map_pixels(|px| px.to_gamma_srgb().to_associated_alpha(px.a()));
|
||||
} else {
|
||||
original_buffer.map_pixels(|px| px.to_associated_alpha(px.a()));
|
||||
}
|
||||
|
||||
let (width, height) = original_buffer.dimensions();
|
||||
|
||||
// Create 1D gaussian kernel
|
||||
let kernel = gaussian_kernel(radius);
|
||||
let half_kernel = kernel.len() / 2;
|
||||
|
||||
// Intermediate buffer for horizontal and vertical passes
|
||||
let mut x_axis = Image::new(width, height, Color::TRANSPARENT);
|
||||
let mut y_axis = Image::new(width, height, Color::TRANSPARENT);
|
||||
|
||||
for pass in [false, true] {
|
||||
let (max, old_buffer, current_buffer) = match pass {
|
||||
false => (width, &original_buffer, &mut x_axis),
|
||||
true => (height, &x_axis, &mut y_axis),
|
||||
};
|
||||
let pass = pass as usize;
|
||||
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let (mut r_sum, mut g_sum, mut b_sum, mut a_sum, mut weight_sum) = (0., 0., 0., 0., 0.);
|
||||
|
||||
for (i, &weight) in kernel.iter().enumerate() {
|
||||
let p = [x, y][pass] as i32 + (i as i32 - half_kernel as i32);
|
||||
|
||||
if p >= 0
|
||||
&& p < max as i32 && let Some(px) = old_buffer.get_pixel([p as u32, x][pass], [y, p as u32][pass])
|
||||
{
|
||||
r_sum += px.r() as f64 * weight;
|
||||
g_sum += px.g() as f64 * weight;
|
||||
b_sum += px.b() as f64 * weight;
|
||||
a_sum += px.a() as f64 * weight;
|
||||
weight_sum += weight;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize
|
||||
let (r, g, b, a) = if weight_sum > 0. {
|
||||
((r_sum / weight_sum) as f32, (g_sum / weight_sum) as f32, (b_sum / weight_sum) as f32, (a_sum / weight_sum) as f32)
|
||||
} else {
|
||||
let px = old_buffer.get_pixel(x, y).unwrap();
|
||||
(px.r(), px.g(), px.b(), px.a())
|
||||
};
|
||||
current_buffer.set_pixel(x, y, Color::from_rgbaf32_unchecked(r, g, b, a));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if gamma {
|
||||
y_axis.map_pixels(|px| px.to_linear_srgb().to_unassociated_alpha());
|
||||
} else {
|
||||
y_axis.map_pixels(|px| px.to_unassociated_alpha());
|
||||
}
|
||||
|
||||
y_axis
|
||||
}
|
||||
|
||||
fn box_blur_algorithm(mut original_buffer: Image<Color>, radius: f64, gamma: bool) -> Image<Color> {
|
||||
if gamma {
|
||||
original_buffer.map_pixels(|px| px.to_gamma_srgb().to_associated_alpha(px.a()));
|
||||
} else {
|
||||
original_buffer.map_pixels(|px| px.to_associated_alpha(px.a()));
|
||||
}
|
||||
|
||||
let (width, height) = original_buffer.dimensions();
|
||||
let mut x_axis = Image::new(width, height, Color::TRANSPARENT);
|
||||
let mut y_axis = Image::new(width, height, Color::TRANSPARENT);
|
||||
|
||||
for pass in [false, true] {
|
||||
let (max, old_buffer, current_buffer) = match pass {
|
||||
false => (width, &original_buffer, &mut x_axis),
|
||||
true => (height, &x_axis, &mut y_axis),
|
||||
};
|
||||
let pass = pass as usize;
|
||||
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let (mut r_sum, mut g_sum, mut b_sum, mut a_sum, mut weight_sum) = (0., 0., 0., 0., 0.);
|
||||
|
||||
let i = [x, y][pass];
|
||||
for d in (i as i32 - radius as i32).max(0)..=(i as i32 + radius as i32).min(max as i32 - 1) {
|
||||
if let Some(px) = old_buffer.get_pixel([d as u32, x][pass], [y, d as u32][pass]) {
|
||||
let weight = 1.;
|
||||
r_sum += px.r() as f64 * weight;
|
||||
g_sum += px.g() as f64 * weight;
|
||||
b_sum += px.b() as f64 * weight;
|
||||
a_sum += px.a() as f64 * weight;
|
||||
weight_sum += weight;
|
||||
}
|
||||
}
|
||||
|
||||
let (r, g, b, a) = ((r_sum / weight_sum) as f32, (g_sum / weight_sum) as f32, (b_sum / weight_sum) as f32, (a_sum / weight_sum) as f32);
|
||||
current_buffer.set_pixel(x, y, Color::from_rgbaf32_unchecked(r, g, b, a));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if gamma {
|
||||
y_axis.map_pixels(|px| px.to_linear_srgb().to_unassociated_alpha());
|
||||
} else {
|
||||
y_axis.map_pixels(|px| px.to_unassociated_alpha());
|
||||
}
|
||||
|
||||
y_axis
|
||||
}
|
||||
29
node-graph/nodes/raster/src/fullscreen_vertex.rs
Normal file
29
node-graph/nodes/raster/src/fullscreen_vertex.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use glam::{Vec2, Vec4};
|
||||
use spirv_std::spirv;
|
||||
|
||||
/// webgpu NDC is like OpenGL: (-1.0 .. 1.0, -1.0 .. 1.0, 0.0 .. 1.0)
|
||||
/// https://www.w3.org/TR/webgpu/#coordinate-systems
|
||||
///
|
||||
/// So to make a fullscreen triangle around a box at (-1..1):
|
||||
///
|
||||
/// ```text
|
||||
/// 3 +
|
||||
/// |\
|
||||
/// 2 | \
|
||||
/// | \
|
||||
/// 1 +-----+
|
||||
/// | |\
|
||||
/// 0 | 0 | \
|
||||
/// | | \
|
||||
/// -1 +-----+-----+
|
||||
/// -1 0 1 2 3
|
||||
/// ```
|
||||
const FULLSCREEN_VERTICES: [Vec2; 3] = [Vec2::new(-1., -1.), Vec2::new(-1., 3.), Vec2::new(3., -1.)];
|
||||
|
||||
#[spirv(vertex)]
|
||||
pub fn fullscreen_vertex(#[spirv(vertex_index)] vertex_index: u32, #[spirv(position)] gl_position: &mut Vec4) {
|
||||
// broken on edition 2024 branch
|
||||
// let vertex = unsafe { *FULLSCREEN_VERTICES.index_unchecked(vertex_index as usize) };
|
||||
let vertex = FULLSCREEN_VERTICES[vertex_index as usize];
|
||||
*gl_position = Vec4::from((vertex, 0., 1.));
|
||||
}
|
||||
45
node-graph/nodes/raster/src/generate_curves.rs
Normal file
45
node-graph/nodes/raster/src/generate_curves.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use crate::curve::{Curve, CurveManipulatorGroup, ValueMapperNode};
|
||||
use core_types::color::{Channel, Linear};
|
||||
use core_types::context::Ctx;
|
||||
use kurbo::{CubicBez, ParamCurve, PathSeg, Point};
|
||||
use vector_types::vector::algorithms::bezpath_algorithms::pathseg_find_tvalues_for_x;
|
||||
|
||||
const WINDOW_SIZE: usize = 1024;
|
||||
|
||||
#[node_macro::node(category(""))]
|
||||
fn generate_curves<C: Channel + Linear>(_: impl Ctx, curve: Curve, #[implementations(f32, f64)] _target_format: C) -> ValueMapperNode<C> {
|
||||
let [mut pos, mut param]: [[f32; 2]; 2] = [[0.; 2], curve.first_handle];
|
||||
let mut lut = vec![C::from_f64(0.); WINDOW_SIZE];
|
||||
let end = CurveManipulatorGroup {
|
||||
anchor: [1.; 2],
|
||||
handles: [curve.last_handle, [0.; 2]],
|
||||
};
|
||||
for sample in curve.manipulator_groups.iter().chain(std::iter::once(&end)) {
|
||||
let [x0, y0, x1, y1, x2, y2, x3, y3] = [pos[0], pos[1], param[0], param[1], sample.handles[0][0], sample.handles[0][1], sample.anchor[0], sample.anchor[1]].map(f64::from);
|
||||
|
||||
let segment = PathSeg::Cubic(CubicBez::new(Point::new(x0, y0), Point::new(x1, y1), Point::new(x2, y2), Point::new(x3, y3)));
|
||||
|
||||
let [left, right] = [pos[0], sample.anchor[0]].map(|c| c.clamp(0., 1.));
|
||||
let lut_index_left: usize = (left * (lut.len() - 1) as f32).floor() as _;
|
||||
let lut_index_right: usize = (right * (lut.len() - 1) as f32).ceil() as _;
|
||||
for index in lut_index_left..=lut_index_right {
|
||||
let x = index as f64 / (lut.len() - 1) as f64;
|
||||
let y = if x <= x0 {
|
||||
y0
|
||||
} else if x >= x3 {
|
||||
y3
|
||||
} else {
|
||||
pathseg_find_tvalues_for_x(segment, x)
|
||||
.next()
|
||||
.map(|t| segment.eval(t.clamp(0., 1.)).y)
|
||||
// Fall back to a very bad approximation if the above fails
|
||||
.unwrap_or_else(|| (x - x0) / (x3 - x0) * (y3 - y0) + y0)
|
||||
};
|
||||
lut[index] = C::from_f64(y);
|
||||
}
|
||||
|
||||
pos = sample.anchor;
|
||||
param = sample.handles[1];
|
||||
}
|
||||
ValueMapperNode::new(lut)
|
||||
}
|
||||
32
node-graph/nodes/raster/src/gradient_map.rs
Normal file
32
node-graph/nodes/raster/src/gradient_map.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
//! Not immediately shader compatible due to needing [`GradientStops`] as a param, which needs [`Vec`]
|
||||
|
||||
use crate::adjust::Adjust;
|
||||
use core_types::table::Table;
|
||||
use core_types::{Color, Ctx};
|
||||
use raster_types::{CPU, Raster};
|
||||
use vector_types::GradientStops;
|
||||
|
||||
// Aims for interoperable compatibility with:
|
||||
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27grdm%27%20%3D%20Gradient%20Map
|
||||
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Gradient%20settings%20(Photoshop%206.0)
|
||||
#[node_macro::node(category("Raster: Adjustment"))]
|
||||
async fn gradient_map<T: Adjust<Color>>(
|
||||
_: impl Ctx,
|
||||
#[implementations(
|
||||
Table<Raster<CPU>>,
|
||||
Table<Color>,
|
||||
Table<GradientStops>,
|
||||
GradientStops,
|
||||
)]
|
||||
mut image: T,
|
||||
gradient: GradientStops,
|
||||
reverse: bool,
|
||||
) -> T {
|
||||
image.adjust(|color| {
|
||||
let intensity = color.luminance_srgb();
|
||||
let intensity = if reverse { 1. - intensity } else { intensity };
|
||||
gradient.evaluate(intensity as f64).to_linear_srgb()
|
||||
});
|
||||
|
||||
image
|
||||
}
|
||||
84
node-graph/nodes/raster/src/image_color_palette.rs
Normal file
84
node-graph/nodes/raster/src/image_color_palette.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
use core_types::color::Color;
|
||||
use core_types::context::Ctx;
|
||||
use core_types::table::{Table, TableRow};
|
||||
use raster_types::{CPU, Raster};
|
||||
|
||||
#[node_macro::node(category("Color"))]
|
||||
async fn image_color_palette(
|
||||
_: impl Ctx,
|
||||
image: Table<Raster<CPU>>,
|
||||
#[hard_min(1.)]
|
||||
#[soft_max(28.)]
|
||||
max_size: u32,
|
||||
) -> Table<Color> {
|
||||
const GRID: f32 = 3.;
|
||||
|
||||
let bins = GRID * GRID * GRID;
|
||||
|
||||
let mut histogram = vec![0; (bins + 1.) as usize];
|
||||
let mut color_bins = vec![Vec::new(); (bins + 1.) as usize];
|
||||
|
||||
for row in image.iter() {
|
||||
for pixel in row.element.data.iter() {
|
||||
let r = pixel.r() * GRID;
|
||||
let g = pixel.g() * GRID;
|
||||
let b = pixel.b() * GRID;
|
||||
|
||||
let bin = (r * GRID + g * GRID + b * GRID) as usize;
|
||||
|
||||
histogram[bin] += 1;
|
||||
color_bins[bin].push(pixel.to_gamma_srgb());
|
||||
}
|
||||
}
|
||||
|
||||
let shorted = histogram.iter().enumerate().filter(|&(_, &count)| count > 0).map(|(i, _)| i).collect::<Vec<usize>>();
|
||||
|
||||
shorted
|
||||
.iter()
|
||||
.take(max_size as usize)
|
||||
.flat_map(|&i| {
|
||||
let list = &color_bins[i];
|
||||
|
||||
let mut r = 0.;
|
||||
let mut g = 0.;
|
||||
let mut b = 0.;
|
||||
let mut a = 0.;
|
||||
|
||||
for color in list.iter() {
|
||||
r += color.r();
|
||||
g += color.g();
|
||||
b += color.b();
|
||||
a += color.a();
|
||||
}
|
||||
|
||||
r /= list.len() as f32;
|
||||
g /= list.len() as f32;
|
||||
b /= list.len() as f32;
|
||||
a /= list.len() as f32;
|
||||
|
||||
Color::from_rgbaf32(r, g, b, a).map(TableRow::new_from_element).into_iter()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use raster_types::Image;
|
||||
use raster_types::Raster;
|
||||
|
||||
#[test]
|
||||
fn test_image_color_palette() {
|
||||
let result = image_color_palette(
|
||||
(),
|
||||
Table::new_from_element(Raster::new_cpu(Image {
|
||||
width: 100,
|
||||
height: 100,
|
||||
data: vec![Color::from_rgbaf32(0., 0., 0., 1.).unwrap(); 10000],
|
||||
base64_string: None,
|
||||
})),
|
||||
1,
|
||||
);
|
||||
assert_eq!(futures::executor::block_on(result), Table::new_from_element(Color::from_rgbaf32(0., 0., 0., 1.).unwrap()));
|
||||
}
|
||||
}
|
||||
26
node-graph/nodes/raster/src/lib.rs
Normal file
26
node-graph/nodes/raster/src/lib.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
#![cfg_attr(not(feature = "std"), no_std)]
|
||||
|
||||
pub mod adjust;
|
||||
pub mod adjustments;
|
||||
pub mod blending_nodes;
|
||||
pub mod cubic_spline;
|
||||
pub mod fullscreen_vertex;
|
||||
|
||||
/// required by shader macro
|
||||
#[cfg(feature = "shader-nodes")]
|
||||
pub use raster_nodes_shaders::WGSL_SHADER;
|
||||
|
||||
#[cfg(feature = "std")]
|
||||
pub mod curve;
|
||||
#[cfg(feature = "std")]
|
||||
pub mod dehaze;
|
||||
#[cfg(feature = "std")]
|
||||
pub mod filter;
|
||||
#[cfg(feature = "std")]
|
||||
pub mod generate_curves;
|
||||
#[cfg(feature = "std")]
|
||||
pub mod gradient_map;
|
||||
#[cfg(feature = "std")]
|
||||
pub mod image_color_palette;
|
||||
#[cfg(feature = "std")]
|
||||
pub mod std_nodes;
|
||||
518
node-graph/nodes/raster/src/std_nodes.rs
Normal file
518
node-graph/nodes/raster/src/std_nodes.rs
Normal file
@@ -0,0 +1,518 @@
|
||||
use crate::adjustments::{CellularDistanceFunction, CellularReturnType, DomainWarpType, FractalType, NoiseType};
|
||||
use core_types::blending::AlphaBlending;
|
||||
use core_types::color::Color;
|
||||
use core_types::color::{Alpha, AlphaMut, Channel, LinearChannel, Luminance, RGBMut};
|
||||
use core_types::context::{Ctx, ExtractFootprint};
|
||||
use core_types::math::bbox::Bbox;
|
||||
use core_types::table::{Table, TableRow};
|
||||
use core_types::transform::Transform;
|
||||
use dyn_any::DynAny;
|
||||
use fastnoise_lite;
|
||||
use glam::{DAffine2, DVec2, Vec2};
|
||||
use rand::prelude::*;
|
||||
use rand_chacha::ChaCha8Rng;
|
||||
use raster_types::Image;
|
||||
use raster_types::{Bitmap, BitmapMut};
|
||||
use raster_types::{CPU, Raster};
|
||||
use std::fmt::Debug;
|
||||
use std::hash::Hash;
|
||||
|
||||
#[derive(Debug, DynAny)]
|
||||
pub enum Error {
|
||||
IO(std::io::Error),
|
||||
Image(::image::ImageError),
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for Error {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
Error::IO(e)
|
||||
}
|
||||
}
|
||||
|
||||
#[node_macro::node(category("Debug: Raster"))]
|
||||
pub fn sample_image(ctx: impl ExtractFootprint + Clone + Send, image_frame: Table<Raster<CPU>>) -> Table<Raster<CPU>> {
|
||||
image_frame
|
||||
.into_iter()
|
||||
.filter_map(|mut row| {
|
||||
let image_frame_transform = row.transform;
|
||||
let image = row.element;
|
||||
|
||||
// Resize the image using the image crate
|
||||
let data = bytemuck::cast_vec(image.data.clone());
|
||||
|
||||
let footprint = ctx.footprint();
|
||||
let viewport_bounds = footprint.viewport_bounds_in_local_space();
|
||||
let image_bounds = Bbox::from_transform(image_frame_transform).to_axis_aligned_bbox();
|
||||
let intersection = viewport_bounds.intersect(&image_bounds);
|
||||
let image_size = DAffine2::from_scale(DVec2::new(image.width as f64, image.height as f64));
|
||||
let size = intersection.size();
|
||||
let size_px = image_size.transform_vector2(size).as_uvec2();
|
||||
|
||||
// If the image would not be visible, add nothing.
|
||||
if size.x <= 0. || size.y <= 0. {
|
||||
return None;
|
||||
}
|
||||
|
||||
let image_buffer = ::image::Rgba32FImage::from_raw(image.width, image.height, data).expect("Failed to convert internal image format into image-rs data type.");
|
||||
|
||||
let dynamic_image: ::image::DynamicImage = image_buffer.into();
|
||||
let offset = (intersection.start - image_bounds.start).max(DVec2::ZERO);
|
||||
let offset_px = image_size.transform_vector2(offset).as_uvec2();
|
||||
let cropped = dynamic_image.crop_imm(offset_px.x, offset_px.y, size_px.x, size_px.y);
|
||||
|
||||
let viewport_resolution_x = footprint.transform.transform_vector2(DVec2::X * size.x).length();
|
||||
let viewport_resolution_y = footprint.transform.transform_vector2(DVec2::Y * size.y).length();
|
||||
let mut new_width = size_px.x;
|
||||
let mut new_height = size_px.y;
|
||||
|
||||
// Only downscale the image for now
|
||||
let resized = if new_width < image.width || new_height < image.height {
|
||||
new_width = viewport_resolution_x as u32;
|
||||
new_height = viewport_resolution_y as u32;
|
||||
// TODO: choose filter based on quality requirements
|
||||
cropped.resize_exact(new_width, new_height, ::image::imageops::Triangle)
|
||||
} else {
|
||||
cropped
|
||||
};
|
||||
let buffer = resized.to_rgba32f();
|
||||
let buffer = buffer.into_raw();
|
||||
let vec = bytemuck::cast_vec(buffer);
|
||||
let image = Image {
|
||||
width: new_width,
|
||||
height: new_height,
|
||||
data: vec,
|
||||
base64_string: None,
|
||||
};
|
||||
// we need to adjust the offset if we truncate the offset calculation
|
||||
|
||||
let new_transform = image_frame_transform * DAffine2::from_translation(offset) * DAffine2::from_scale(size);
|
||||
|
||||
row.transform = new_transform;
|
||||
row.element = Raster::new_cpu(image);
|
||||
Some(row)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[node_macro::node(category("Raster: Channels"))]
|
||||
pub fn combine_channels(
|
||||
_: impl Ctx,
|
||||
_primary: (),
|
||||
#[expose] red: Table<Raster<CPU>>,
|
||||
#[expose] green: Table<Raster<CPU>>,
|
||||
#[expose] blue: Table<Raster<CPU>>,
|
||||
#[expose] alpha: Table<Raster<CPU>>,
|
||||
) -> Table<Raster<CPU>> {
|
||||
let max_len = red.len().max(green.len()).max(blue.len()).max(alpha.len());
|
||||
let red = red.into_iter().map(Some).chain(std::iter::repeat(None)).take(max_len);
|
||||
let green = green.into_iter().map(Some).chain(std::iter::repeat(None)).take(max_len);
|
||||
let blue = blue.into_iter().map(Some).chain(std::iter::repeat(None)).take(max_len);
|
||||
let alpha = alpha.into_iter().map(Some).chain(std::iter::repeat(None)).take(max_len);
|
||||
|
||||
red.zip(green)
|
||||
.zip(blue)
|
||||
.zip(alpha)
|
||||
.filter_map(|(((red, green), blue), alpha)| {
|
||||
// Turn any default zero-sized image rows into None
|
||||
let red = red.filter(|i| i.element.width > 0 && i.element.height > 0);
|
||||
let green = green.filter(|i| i.element.width > 0 && i.element.height > 0);
|
||||
let blue = blue.filter(|i| i.element.width > 0 && i.element.height > 0);
|
||||
let alpha = alpha.filter(|i| i.element.width > 0 && i.element.height > 0);
|
||||
|
||||
// Get this row's transform and alpha blending mode from the first non-empty channel
|
||||
let (transform, alpha_blending, source_node_id) = [&red, &green, &blue, &alpha]
|
||||
.iter()
|
||||
.find_map(|i| i.as_ref())
|
||||
.map(|i| (i.transform, i.alpha_blending, i.source_node_id))?;
|
||||
|
||||
// Get the common width and height of the channels, which must have equal dimensions
|
||||
let channel_dimensions = [
|
||||
red.as_ref().map(|r| (r.element.width, r.element.height)),
|
||||
green.as_ref().map(|g| (g.element.width, g.element.height)),
|
||||
blue.as_ref().map(|b| (b.element.width, b.element.height)),
|
||||
alpha.as_ref().map(|a| (a.element.width, a.element.height)),
|
||||
];
|
||||
if channel_dimensions.iter().all(Option::is_none)
|
||||
|| channel_dimensions
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|&(x, y)| channel_dimensions.iter().flatten().any(|&(other_x, other_y)| x != other_x || y != other_y))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let &(width, height) = channel_dimensions.iter().flatten().next()?;
|
||||
|
||||
// Create a new image for the output element
|
||||
let mut image = Image::new(width, height, Color::TRANSPARENT);
|
||||
|
||||
// Iterate over all pixels in the image and set the color channels
|
||||
for y in 0..image.height() {
|
||||
for x in 0..image.width() {
|
||||
let image_pixel = image.get_pixel_mut(x, y).unwrap();
|
||||
|
||||
if let Some(r) = red.as_ref().and_then(|r| r.element.get_pixel(x, y)) {
|
||||
image_pixel.set_red(r.l().cast_linear_channel());
|
||||
} else {
|
||||
image_pixel.set_red(Channel::from_linear(0.));
|
||||
}
|
||||
if let Some(g) = green.as_ref().and_then(|g| g.element.get_pixel(x, y)) {
|
||||
image_pixel.set_green(g.l().cast_linear_channel());
|
||||
} else {
|
||||
image_pixel.set_green(Channel::from_linear(0.));
|
||||
}
|
||||
if let Some(b) = blue.as_ref().and_then(|b| b.element.get_pixel(x, y)) {
|
||||
image_pixel.set_blue(b.l().cast_linear_channel());
|
||||
} else {
|
||||
image_pixel.set_blue(Channel::from_linear(0.));
|
||||
}
|
||||
if let Some(a) = alpha.as_ref().and_then(|a| a.element.get_pixel(x, y)) {
|
||||
image_pixel.set_alpha(a.l().cast_linear_channel());
|
||||
} else {
|
||||
image_pixel.set_alpha(Channel::from_linear(1.));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(TableRow {
|
||||
element: Raster::new_cpu(image),
|
||||
transform,
|
||||
alpha_blending,
|
||||
source_node_id,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[node_macro::node(category("Raster"))]
|
||||
pub fn mask(
|
||||
_: impl Ctx,
|
||||
/// The image to be masked.
|
||||
image: Table<Raster<CPU>>,
|
||||
/// The stencil to be used for masking.
|
||||
#[expose]
|
||||
stencil: Table<Raster<CPU>>,
|
||||
) -> Table<Raster<CPU>> {
|
||||
// TODO: Figure out what it means to support multiple stencil rows?
|
||||
let Some(stencil) = stencil.into_iter().next() else {
|
||||
// No stencil provided so we return the original image
|
||||
return image;
|
||||
};
|
||||
let stencil_size = DVec2::new(stencil.element.width as f64, stencil.element.height as f64);
|
||||
|
||||
image
|
||||
.into_iter()
|
||||
.filter_map(|mut row| {
|
||||
let image_size = DVec2::new(row.element.width as f64, row.element.height as f64);
|
||||
let mask_size = stencil.transform.decompose_scale();
|
||||
|
||||
if mask_size == DVec2::ZERO {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Transforms a point from the background image to the foreground image
|
||||
let bg_to_fg = row.transform * DAffine2::from_scale(1. / image_size);
|
||||
let stencil_transform_inverse = stencil.transform.inverse();
|
||||
|
||||
for y in 0..row.element.height {
|
||||
for x in 0..row.element.width {
|
||||
let image_point = DVec2::new(x as f64, y as f64);
|
||||
let mask_point = bg_to_fg.transform_point2(image_point);
|
||||
let local_mask_point = stencil_transform_inverse.transform_point2(mask_point);
|
||||
let mask_point = stencil.transform.transform_point2(local_mask_point.clamp(DVec2::ZERO, DVec2::ONE));
|
||||
let mask_point = (DAffine2::from_scale(stencil_size) * stencil.transform.inverse()).transform_point2(mask_point);
|
||||
|
||||
let image_pixel = row.element.data_mut().get_pixel_mut(x, y).unwrap();
|
||||
let mask_pixel = stencil.element.sample(mask_point);
|
||||
*image_pixel = image_pixel.multiplied_alpha(mask_pixel.l().cast_linear_channel());
|
||||
}
|
||||
}
|
||||
|
||||
Some(row)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[node_macro::node(category(""))]
|
||||
pub fn extend_image_to_bounds(_: impl Ctx, image: Table<Raster<CPU>>, bounds: DAffine2) -> Table<Raster<CPU>> {
|
||||
image
|
||||
.into_iter()
|
||||
.map(|mut row| {
|
||||
let image_aabb = Bbox::unit().affine_transform(row.transform).to_axis_aligned_bbox();
|
||||
let bounds_aabb = Bbox::unit().affine_transform(bounds.transform()).to_axis_aligned_bbox();
|
||||
if image_aabb.contains(bounds_aabb.start) && image_aabb.contains(bounds_aabb.end) {
|
||||
return row;
|
||||
}
|
||||
|
||||
let image_data = &row.element.data;
|
||||
let (image_width, image_height) = (row.element.width, row.element.height);
|
||||
if image_width == 0 || image_height == 0 {
|
||||
return empty_image((), bounds, Table::new_from_element(Color::TRANSPARENT)).into_iter().next().unwrap();
|
||||
}
|
||||
|
||||
let orig_image_scale = DVec2::new(image_width as f64, image_height as f64);
|
||||
let layer_to_image_space = DAffine2::from_scale(orig_image_scale) * row.transform.inverse();
|
||||
let bounds_in_image_space = Bbox::unit().affine_transform(layer_to_image_space * bounds).to_axis_aligned_bbox();
|
||||
|
||||
let new_start = bounds_in_image_space.start.floor().min(DVec2::ZERO);
|
||||
let new_end = bounds_in_image_space.end.ceil().max(orig_image_scale);
|
||||
let new_scale = new_end - new_start;
|
||||
|
||||
// Copy over original image into enlarged image.
|
||||
let mut new_image = Image::new(new_scale.x as u32, new_scale.y as u32, Color::TRANSPARENT);
|
||||
let offset_in_new_image = (-new_start).as_uvec2();
|
||||
for y in 0..image_height {
|
||||
let old_start = y * image_width;
|
||||
let new_start = (y + offset_in_new_image.y) * new_image.width + offset_in_new_image.x;
|
||||
let old_row = &image_data[old_start as usize..(old_start + image_width) as usize];
|
||||
let new_row = &mut new_image.data[new_start as usize..(new_start + image_width) as usize];
|
||||
new_row.copy_from_slice(old_row);
|
||||
}
|
||||
|
||||
// Compute new transform.
|
||||
// let layer_to_new_texture_space = (DAffine2::from_scale(1. / new_scale) * DAffine2::from_translation(new_start) * layer_to_image_space).inverse();
|
||||
let new_texture_to_layer_space = row.transform * DAffine2::from_scale(1. / orig_image_scale) * DAffine2::from_translation(new_start) * DAffine2::from_scale(new_scale);
|
||||
|
||||
row.element = Raster::new_cpu(new_image);
|
||||
row.transform = new_texture_to_layer_space;
|
||||
row
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[node_macro::node(category("Debug: Raster"))]
|
||||
pub fn empty_image(_: impl Ctx, transform: DAffine2, color: Table<Color>) -> Table<Raster<CPU>> {
|
||||
let width = transform.transform_vector2(DVec2::new(1., 0.)).length() as u32;
|
||||
let height = transform.transform_vector2(DVec2::new(0., 1.)).length() as u32;
|
||||
|
||||
let color: Option<Color> = color.into();
|
||||
let image = Image::new(width, height, color.unwrap_or(Color::WHITE));
|
||||
|
||||
let mut result_table = Table::new_from_element(Raster::new_cpu(image));
|
||||
let row = result_table.get_mut(0).unwrap();
|
||||
*row.transform = transform;
|
||||
*row.alpha_blending = AlphaBlending::default();
|
||||
|
||||
// Callers of empty_image can safely unwrap on returned table
|
||||
result_table
|
||||
}
|
||||
|
||||
/// Constructs a raster image.
|
||||
#[node_macro::node(category(""))]
|
||||
pub fn image_value(_: impl Ctx, _primary: (), image: Table<Raster<CPU>>) -> Table<Raster<CPU>> {
|
||||
image
|
||||
}
|
||||
|
||||
#[node_macro::node(category("Raster: Pattern"))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn noise_pattern(
|
||||
ctx: impl ExtractFootprint + Ctx,
|
||||
_primary: (),
|
||||
clip: bool,
|
||||
seed: u32,
|
||||
scale: f64,
|
||||
noise_type: NoiseType,
|
||||
domain_warp_type: DomainWarpType,
|
||||
domain_warp_amplitude: f64,
|
||||
fractal_type: FractalType,
|
||||
fractal_octaves: u32,
|
||||
fractal_lacunarity: f64,
|
||||
fractal_gain: f64,
|
||||
fractal_weighted_strength: f64,
|
||||
fractal_ping_pong_strength: f64,
|
||||
cellular_distance_function: CellularDistanceFunction,
|
||||
cellular_return_type: CellularReturnType,
|
||||
cellular_jitter: f64,
|
||||
) -> Table<Raster<CPU>> {
|
||||
let footprint = ctx.footprint();
|
||||
let viewport_bounds = footprint.viewport_bounds_in_local_space();
|
||||
|
||||
let mut size = viewport_bounds.size();
|
||||
let mut offset = viewport_bounds.start;
|
||||
if clip {
|
||||
// TODO: Remove "clip" entirely (and its arbitrary 100x100 clipping square) once we have proper resolution-aware layer clipping
|
||||
const CLIPPING_SQUARE_SIZE: f64 = 100.;
|
||||
let image_bounds = Bbox::from_transform(DAffine2::from_scale(DVec2::splat(CLIPPING_SQUARE_SIZE))).to_axis_aligned_bbox();
|
||||
let intersection = viewport_bounds.intersect(&image_bounds);
|
||||
|
||||
offset = (intersection.start - image_bounds.start).max(DVec2::ZERO);
|
||||
size = intersection.size();
|
||||
}
|
||||
|
||||
// If the image would not be visible, return an empty image
|
||||
if size.x <= 0. || size.y <= 0. {
|
||||
return Table::new();
|
||||
}
|
||||
|
||||
let footprint_scale = footprint.scale();
|
||||
let width = (size.x * footprint_scale.x) as u32;
|
||||
let height = (size.y * footprint_scale.y) as u32;
|
||||
|
||||
// All
|
||||
let mut image = Image::new(width, height, Color::from_luminance(0.5));
|
||||
let mut noise = fastnoise_lite::FastNoiseLite::with_seed(seed as i32);
|
||||
noise.set_frequency(Some(1. / (scale as f32).max(f32::EPSILON)));
|
||||
|
||||
// Domain Warp
|
||||
let domain_warp_type = match domain_warp_type {
|
||||
DomainWarpType::None => None,
|
||||
DomainWarpType::OpenSimplex2 => Some(fastnoise_lite::DomainWarpType::OpenSimplex2),
|
||||
DomainWarpType::OpenSimplex2Reduced => Some(fastnoise_lite::DomainWarpType::OpenSimplex2Reduced),
|
||||
DomainWarpType::BasicGrid => Some(fastnoise_lite::DomainWarpType::BasicGrid),
|
||||
};
|
||||
let domain_warp_active = domain_warp_type.is_some();
|
||||
noise.set_domain_warp_type(domain_warp_type);
|
||||
noise.set_domain_warp_amp(Some(domain_warp_amplitude as f32));
|
||||
|
||||
// Fractal
|
||||
let noise_type = match noise_type {
|
||||
NoiseType::Perlin => fastnoise_lite::NoiseType::Perlin,
|
||||
NoiseType::OpenSimplex2 => fastnoise_lite::NoiseType::OpenSimplex2,
|
||||
NoiseType::OpenSimplex2S => fastnoise_lite::NoiseType::OpenSimplex2S,
|
||||
NoiseType::Cellular => fastnoise_lite::NoiseType::Cellular,
|
||||
NoiseType::ValueCubic => fastnoise_lite::NoiseType::ValueCubic,
|
||||
NoiseType::Value => fastnoise_lite::NoiseType::Value,
|
||||
NoiseType::WhiteNoise => {
|
||||
// TODO: Generate in layer space, not viewport space
|
||||
|
||||
let mut rng = ChaCha8Rng::seed_from_u64(seed as u64);
|
||||
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let pixel = image.get_pixel_mut(x, y).unwrap();
|
||||
let luminance = rng.random_range(0.0..1.) as f32;
|
||||
*pixel = Color::from_luminance(luminance);
|
||||
}
|
||||
}
|
||||
|
||||
return Table::new_from_row(TableRow {
|
||||
element: Raster::new_cpu(image),
|
||||
transform: DAffine2::from_translation(offset) * DAffine2::from_scale(size),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
};
|
||||
noise.set_noise_type(Some(noise_type));
|
||||
let fractal_type = match fractal_type {
|
||||
FractalType::None => fastnoise_lite::FractalType::None,
|
||||
FractalType::FBm => fastnoise_lite::FractalType::FBm,
|
||||
FractalType::Ridged => fastnoise_lite::FractalType::Ridged,
|
||||
FractalType::PingPong => fastnoise_lite::FractalType::PingPong,
|
||||
FractalType::DomainWarpProgressive => fastnoise_lite::FractalType::DomainWarpProgressive,
|
||||
FractalType::DomainWarpIndependent => fastnoise_lite::FractalType::DomainWarpIndependent,
|
||||
};
|
||||
noise.set_fractal_type(Some(fractal_type));
|
||||
noise.set_fractal_octaves(Some(fractal_octaves as i32));
|
||||
noise.set_fractal_lacunarity(Some(fractal_lacunarity as f32));
|
||||
noise.set_fractal_gain(Some(fractal_gain as f32));
|
||||
noise.set_fractal_weighted_strength(Some(fractal_weighted_strength as f32));
|
||||
noise.set_fractal_ping_pong_strength(Some(fractal_ping_pong_strength as f32));
|
||||
|
||||
// Cellular
|
||||
let cellular_distance_function = match cellular_distance_function {
|
||||
CellularDistanceFunction::Euclidean => fastnoise_lite::CellularDistanceFunction::Euclidean,
|
||||
CellularDistanceFunction::EuclideanSq => fastnoise_lite::CellularDistanceFunction::EuclideanSq,
|
||||
CellularDistanceFunction::Manhattan => fastnoise_lite::CellularDistanceFunction::Manhattan,
|
||||
CellularDistanceFunction::Hybrid => fastnoise_lite::CellularDistanceFunction::Hybrid,
|
||||
};
|
||||
let cellular_return_type = match cellular_return_type {
|
||||
CellularReturnType::CellValue => fastnoise_lite::CellularReturnType::CellValue,
|
||||
CellularReturnType::Nearest => fastnoise_lite::CellularReturnType::Distance,
|
||||
CellularReturnType::NextNearest => fastnoise_lite::CellularReturnType::Distance2,
|
||||
CellularReturnType::Average => fastnoise_lite::CellularReturnType::Distance2Add,
|
||||
CellularReturnType::Difference => fastnoise_lite::CellularReturnType::Distance2Sub,
|
||||
CellularReturnType::Product => fastnoise_lite::CellularReturnType::Distance2Mul,
|
||||
CellularReturnType::Division => fastnoise_lite::CellularReturnType::Distance2Div,
|
||||
};
|
||||
noise.set_cellular_distance_function(Some(cellular_distance_function));
|
||||
noise.set_cellular_return_type(Some(cellular_return_type));
|
||||
noise.set_cellular_jitter(Some(cellular_jitter as f32));
|
||||
|
||||
let coordinate_offset = offset.as_vec2();
|
||||
let scale = size.as_vec2() / Vec2::new(width as f32, height as f32);
|
||||
// Calculate the noise for every pixel
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let pixel = image.get_pixel_mut(x, y).unwrap();
|
||||
let pos = Vec2::new(x as f32, y as f32);
|
||||
let vec = pos * scale + coordinate_offset;
|
||||
|
||||
let (mut x, mut y) = (vec.x, vec.y);
|
||||
if domain_warp_active && domain_warp_amplitude > 0. {
|
||||
(x, y) = noise.domain_warp_2d(x, y);
|
||||
}
|
||||
|
||||
let luminance = (noise.get_noise_2d(x, y) + 1.) * 0.5;
|
||||
*pixel = Color::from_luminance(luminance);
|
||||
}
|
||||
}
|
||||
|
||||
Table::new_from_row(TableRow {
|
||||
element: Raster::new_cpu(image),
|
||||
transform: DAffine2::from_translation(offset) * DAffine2::from_scale(size),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
#[node_macro::node(category("Raster: Pattern"))]
|
||||
pub fn mandelbrot(ctx: impl ExtractFootprint + Send) -> Table<Raster<CPU>> {
|
||||
let footprint = ctx.footprint();
|
||||
let viewport_bounds = footprint.viewport_bounds_in_local_space();
|
||||
|
||||
let image_bounds = Bbox::from_transform(DAffine2::IDENTITY).to_axis_aligned_bbox();
|
||||
let intersection = viewport_bounds.intersect(&image_bounds);
|
||||
let size = intersection.size();
|
||||
|
||||
let offset = (intersection.start - image_bounds.start).max(DVec2::ZERO);
|
||||
|
||||
// If the image would not be visible, return an empty image
|
||||
if size.x <= 0. || size.y <= 0. {
|
||||
return Table::new();
|
||||
}
|
||||
|
||||
let scale = footprint.scale();
|
||||
let width = (size.x * scale.x) as u32;
|
||||
let height = (size.y * scale.y) as u32;
|
||||
|
||||
let mut data = Vec::with_capacity(width as usize * height as usize);
|
||||
let max_iter = 255;
|
||||
|
||||
let scale = 3. * size.as_vec2() / Vec2::new(width as f32, height as f32);
|
||||
let coordinate_offset = offset.as_vec2() * 3. - Vec2::new(2., 1.5);
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let pos = Vec2::new(x as f32, y as f32);
|
||||
let c = pos * scale + coordinate_offset;
|
||||
|
||||
let iter = mandelbrot_impl(c, max_iter);
|
||||
data.push(map_color(iter, max_iter));
|
||||
}
|
||||
}
|
||||
|
||||
Table::new_from_row(TableRow {
|
||||
element: Raster::new_cpu(Image {
|
||||
width,
|
||||
height,
|
||||
data,
|
||||
..Default::default()
|
||||
}),
|
||||
transform: DAffine2::from_translation(offset) * DAffine2::from_scale(size),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn mandelbrot_impl(c: Vec2, max_iter: usize) -> usize {
|
||||
let mut z = Vec2::new(0., 0.);
|
||||
for i in 0..max_iter {
|
||||
z = Vec2::new(z.x * z.x - z.y * z.y, 2. * z.x * z.y) + c;
|
||||
if z.length_squared() > 4. {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
max_iter
|
||||
}
|
||||
|
||||
fn map_color(iter: usize, max_iter: usize) -> Color {
|
||||
let v = iter as f32 / max_iter as f32;
|
||||
Color::from_rgbaf32_unchecked(v, v, v, 1.)
|
||||
}
|
||||
26
node-graph/nodes/text/Cargo.toml
Normal file
26
node-graph/nodes/text/Cargo.toml
Normal file
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "text-nodes"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Text operation nodes for Graphene"
|
||||
authors = ["Graphite Authors <contact@graphite.rs>"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[features]
|
||||
default = ["serde"]
|
||||
|
||||
[dependencies]
|
||||
# Local dependencies
|
||||
core-types = { workspace = true }
|
||||
vector-types = { workspace = true }
|
||||
node-macro = { workspace = true }
|
||||
|
||||
# Workspace dependencies
|
||||
dyn-any = { workspace = true }
|
||||
glam = { workspace = true }
|
||||
parley = { workspace = true }
|
||||
skrifa = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
# Optional workspace dependencies
|
||||
serde = { workspace = true, optional = true }
|
||||
100
node-graph/nodes/text/src/font_cache.rs
Normal file
100
node-graph/nodes/text/src/font_cache.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
use dyn_any::DynAny;
|
||||
use parley::fontique::Blob;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
// Import specta so derive macros can find it
|
||||
use core_types::specta;
|
||||
|
||||
/// A font type (storing font family and font style and an optional preview URL)
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Hash, PartialEq, Eq, DynAny, core_types::specta::Type)]
|
||||
pub struct Font {
|
||||
#[serde(rename = "fontFamily")]
|
||||
pub font_family: String,
|
||||
#[serde(rename = "fontStyle", deserialize_with = "migrate_font_style")]
|
||||
pub font_style: String,
|
||||
}
|
||||
impl Font {
|
||||
pub fn new(font_family: String, font_style: String) -> Self {
|
||||
Self { font_family, font_style }
|
||||
}
|
||||
}
|
||||
impl Default for Font {
|
||||
fn default() -> Self {
|
||||
Self::new(core_types::consts::DEFAULT_FONT_FAMILY.into(), core_types::consts::DEFAULT_FONT_STYLE.into())
|
||||
}
|
||||
}
|
||||
/// A cache of all loaded font data and preview urls along with the default font (send from `init_app` in `editor_api.rs`)
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize, Default, PartialEq, DynAny)]
|
||||
pub struct FontCache {
|
||||
/// Actual font file data used for rendering a font
|
||||
font_file_data: HashMap<Font, Vec<u8>>,
|
||||
/// Web font preview URLs used for showing fonts when live editing
|
||||
preview_urls: HashMap<Font, String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for FontCache {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("FontCache")
|
||||
.field("font_file_data", &self.font_file_data.keys().collect::<Vec<_>>())
|
||||
.field("preview_urls", &self.preview_urls)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl FontCache {
|
||||
/// Returns the font family name if the font is cached, otherwise returns the fallback font family name if that is cached
|
||||
pub fn resolve_font<'a>(&'a self, font: &'a Font) -> Option<&'a Font> {
|
||||
if self.font_file_data.contains_key(font) {
|
||||
Some(font)
|
||||
} else {
|
||||
self.font_file_data
|
||||
.keys()
|
||||
.find(|font| font.font_family == core_types::consts::DEFAULT_FONT_FAMILY && font.font_style == core_types::consts::DEFAULT_FONT_STYLE)
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to get the bytes for a font
|
||||
pub fn get<'a>(&'a self, font: &'a Font) -> Option<(&'a Vec<u8>, &'a Font)> {
|
||||
self.resolve_font(font).and_then(|font| self.font_file_data.get(font).map(|data| (data, font)))
|
||||
}
|
||||
|
||||
/// Get font data as a Blob for use with parley/skrifa
|
||||
pub fn get_blob<'a>(&'a self, font: &'a Font) -> Option<(Blob<u8>, &'a Font)> {
|
||||
self.get(font).map(|(data, font)| (Blob::new(Arc::new(data.clone())), font))
|
||||
}
|
||||
|
||||
/// Check if the font is already loaded
|
||||
pub fn loaded_font(&self, font: &Font) -> bool {
|
||||
self.font_file_data.contains_key(font)
|
||||
}
|
||||
|
||||
/// Insert a new font into the cache
|
||||
pub fn insert(&mut self, font: Font, perview_url: String, data: Vec<u8>) {
|
||||
self.font_file_data.insert(font.clone(), data);
|
||||
self.preview_urls.insert(font, perview_url);
|
||||
}
|
||||
|
||||
/// Gets the preview URL for showing in text field when live editing
|
||||
pub fn get_preview_url(&self, font: &Font) -> Option<&String> {
|
||||
self.preview_urls.get(font)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::hash::Hash for FontCache {
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
self.preview_urls.len().hash(state);
|
||||
self.preview_urls.iter().for_each(|(font, url)| {
|
||||
font.hash(state);
|
||||
url.hash(state)
|
||||
});
|
||||
self.font_file_data.len().hash(state);
|
||||
self.font_file_data.keys().for_each(|font| font.hash(state));
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Eventually remove this migration document upgrade code
|
||||
fn migrate_font_style<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
|
||||
use serde::Deserialize;
|
||||
String::deserialize(deserializer).map(|name| if name == "Normal (400)" { "Regular (400)".to_string() } else { name })
|
||||
}
|
||||
66
node-graph/nodes/text/src/lib.rs
Normal file
66
node-graph/nodes/text/src/lib.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
mod font_cache;
|
||||
mod path_builder;
|
||||
mod text_context;
|
||||
mod to_path;
|
||||
|
||||
use dyn_any::DynAny;
|
||||
pub use font_cache::*;
|
||||
pub use text_context::TextContext;
|
||||
pub use to_path::*;
|
||||
|
||||
// Re-export for convenience
|
||||
pub use core_types as gcore;
|
||||
pub use vector_types;
|
||||
|
||||
// Import specta so derive macros can find it
|
||||
use core_types::specta;
|
||||
|
||||
/// Alignment of lines of type within a text block.
|
||||
#[repr(C)]
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, Hash, DynAny, core_types::specta::Type, node_macro::ChoiceType)]
|
||||
#[widget(Radio)]
|
||||
pub enum TextAlign {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
#[label("Justify")]
|
||||
JustifyLeft,
|
||||
// TODO: JustifyCenter, JustifyRight, JustifyAll
|
||||
}
|
||||
|
||||
impl From<TextAlign> for parley::Alignment {
|
||||
fn from(val: TextAlign) -> Self {
|
||||
match val {
|
||||
TextAlign::Left => parley::Alignment::Left,
|
||||
TextAlign::Center => parley::Alignment::Middle,
|
||||
TextAlign::Right => parley::Alignment::Right,
|
||||
TextAlign::JustifyLeft => parley::Alignment::Justified,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct TypesettingConfig {
|
||||
pub font_size: f64,
|
||||
pub line_height_ratio: f64,
|
||||
pub character_spacing: f64,
|
||||
pub max_width: Option<f64>,
|
||||
pub max_height: Option<f64>,
|
||||
pub tilt: f64,
|
||||
pub align: TextAlign,
|
||||
}
|
||||
|
||||
impl Default for TypesettingConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
font_size: 24.,
|
||||
line_height_ratio: 1.2,
|
||||
character_spacing: 0.,
|
||||
max_width: None,
|
||||
max_height: None,
|
||||
tilt: 0.,
|
||||
align: TextAlign::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
158
node-graph/nodes/text/src/path_builder.rs
Normal file
158
node-graph/nodes/text/src/path_builder.rs
Normal file
@@ -0,0 +1,158 @@
|
||||
use core_types::table::{Table, TableRow};
|
||||
use glam::{DAffine2, DVec2};
|
||||
use parley::GlyphRun;
|
||||
use skrifa::GlyphId;
|
||||
use skrifa::instance::{LocationRef, NormalizedCoord, Size};
|
||||
use skrifa::outline::{DrawSettings, OutlinePen};
|
||||
use skrifa::raw::FontRef as ReadFontsRef;
|
||||
use skrifa::{MetadataProvider, OutlineGlyph};
|
||||
use vector_types::subpath::{ManipulatorGroup, Subpath};
|
||||
use vector_types::vector::{PointId, Vector};
|
||||
|
||||
pub struct PathBuilder<Upstream> {
|
||||
current_subpath: Subpath<PointId>,
|
||||
origin: DVec2,
|
||||
glyph_subpaths: Vec<Subpath<PointId>>,
|
||||
pub vector_table: Table<Vector<Upstream>>,
|
||||
scale: f64,
|
||||
id: PointId,
|
||||
}
|
||||
|
||||
impl<Upstream: Default + 'static> PathBuilder<Upstream> {
|
||||
pub fn new(per_glyph_instances: bool, scale: f64) -> Self {
|
||||
Self {
|
||||
current_subpath: Subpath::new(Vec::new(), false),
|
||||
glyph_subpaths: Vec::new(),
|
||||
vector_table: if per_glyph_instances { Table::new() } else { Table::new_from_element(Vector::default()) },
|
||||
scale,
|
||||
id: PointId::ZERO,
|
||||
origin: DVec2::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn point(&self, x: f32, y: f32) -> DVec2 {
|
||||
DVec2::new(self.origin.x + x as f64, self.origin.y - y as f64) * self.scale
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_glyph(&mut self, glyph: &OutlineGlyph<'_>, size: f32, normalized_coords: &[NormalizedCoord], glyph_offset: DVec2, style_skew: Option<DAffine2>, skew: DAffine2, per_glyph_instances: bool) {
|
||||
let location_ref = LocationRef::new(normalized_coords);
|
||||
let settings = DrawSettings::unhinted(Size::new(size), location_ref);
|
||||
glyph.draw(settings, self).unwrap();
|
||||
|
||||
// Apply transforms in correct order: style-based skew first, then user-requested skew
|
||||
// This ensures font synthesis (italic) is applied before user transformations
|
||||
for glyph_subpath in &mut self.glyph_subpaths {
|
||||
if let Some(style_skew) = style_skew {
|
||||
glyph_subpath.apply_transform(style_skew);
|
||||
}
|
||||
|
||||
glyph_subpath.apply_transform(skew);
|
||||
}
|
||||
|
||||
if per_glyph_instances {
|
||||
self.vector_table.push(TableRow {
|
||||
element: Vector::from_subpaths(core::mem::take(&mut self.glyph_subpaths), false),
|
||||
transform: DAffine2::from_translation(glyph_offset),
|
||||
..Default::default()
|
||||
});
|
||||
} else {
|
||||
for subpath in self.glyph_subpaths.drain(..) {
|
||||
// Unwrapping here is ok because `self.vector_table` is initialized with a single `Vector` table element
|
||||
self.vector_table.get_mut(0).unwrap().element.append_subpath(subpath, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_glyph_run(&mut self, glyph_run: &GlyphRun<'_, ()>, tilt: f64, per_glyph_instances: bool) {
|
||||
let mut run_x = glyph_run.offset();
|
||||
let run_y = glyph_run.baseline();
|
||||
|
||||
let run = glyph_run.run();
|
||||
|
||||
// User-requested tilt applied around baseline to avoid vertical displacement
|
||||
// Translation ensures rotation point is at the baseline, not origin
|
||||
let skew = if per_glyph_instances {
|
||||
DAffine2::from_cols_array(&[1., 0., -tilt.to_radians().tan(), 1., 0., 0.])
|
||||
} else {
|
||||
DAffine2::from_translation(DVec2::new(0., run_y as f64))
|
||||
* DAffine2::from_cols_array(&[1., 0., -tilt.to_radians().tan(), 1., 0., 0.])
|
||||
* DAffine2::from_translation(DVec2::new(0., -run_y as f64))
|
||||
};
|
||||
|
||||
let synthesis = run.synthesis();
|
||||
|
||||
// Font synthesis (e.g., synthetic italic) applied separately from user transforms
|
||||
// This preserves the distinction between font styling and user transformations
|
||||
let style_skew = synthesis.skew().map(|angle| {
|
||||
if per_glyph_instances {
|
||||
DAffine2::from_cols_array(&[1., 0., -angle.to_radians().tan() as f64, 1., 0., 0.])
|
||||
} else {
|
||||
DAffine2::from_translation(DVec2::new(0., run_y as f64))
|
||||
* DAffine2::from_cols_array(&[1., 0., -angle.to_radians().tan() as f64, 1., 0., 0.])
|
||||
* DAffine2::from_translation(DVec2::new(0., -run_y as f64))
|
||||
}
|
||||
});
|
||||
|
||||
let font = run.font();
|
||||
let font_size = run.font_size();
|
||||
|
||||
let normalized_coords = run.normalized_coords().iter().map(|coord| NormalizedCoord::from_bits(*coord)).collect::<Vec<_>>();
|
||||
|
||||
// TODO: This can be cached for better performance
|
||||
let font_collection_ref = font.data.as_ref();
|
||||
let font_ref = ReadFontsRef::from_index(font_collection_ref, font.index).unwrap();
|
||||
let outlines = font_ref.outline_glyphs();
|
||||
|
||||
for glyph in glyph_run.glyphs() {
|
||||
let glyph_offset = DVec2::new((run_x + glyph.x) as f64, (run_y - glyph.y) as f64);
|
||||
run_x += glyph.advance;
|
||||
|
||||
let glyph_id = GlyphId::from(glyph.id);
|
||||
if let Some(glyph_outline) = outlines.get(glyph_id) {
|
||||
if !per_glyph_instances {
|
||||
self.origin = glyph_offset;
|
||||
}
|
||||
self.draw_glyph(&glyph_outline, font_size, &normalized_coords, glyph_offset, style_skew, skew, per_glyph_instances);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finalize(mut self) -> Table<Vector<Upstream>> {
|
||||
if self.vector_table.is_empty() {
|
||||
self.vector_table = Table::new_from_element(Vector::default());
|
||||
}
|
||||
self.vector_table
|
||||
}
|
||||
}
|
||||
|
||||
impl<Upstream: Default + 'static> OutlinePen for PathBuilder<Upstream> {
|
||||
fn move_to(&mut self, x: f32, y: f32) {
|
||||
if !self.current_subpath.is_empty() {
|
||||
self.glyph_subpaths.push(std::mem::replace(&mut self.current_subpath, Subpath::new(Vec::new(), false)));
|
||||
}
|
||||
self.current_subpath.push_manipulator_group(ManipulatorGroup::new_anchor_with_id(self.point(x, y), self.id.next_id()));
|
||||
}
|
||||
|
||||
fn line_to(&mut self, x: f32, y: f32) {
|
||||
self.current_subpath.push_manipulator_group(ManipulatorGroup::new_anchor_with_id(self.point(x, y), self.id.next_id()));
|
||||
}
|
||||
|
||||
fn quad_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32) {
|
||||
let [handle, anchor] = [self.point(x1, y1), self.point(x2, y2)];
|
||||
self.current_subpath.last_manipulator_group_mut().unwrap().out_handle = Some(handle);
|
||||
self.current_subpath.push_manipulator_group(ManipulatorGroup::new_with_id(anchor, None, None, self.id.next_id()));
|
||||
}
|
||||
|
||||
fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x3: f32, y3: f32) {
|
||||
let [handle1, handle2, anchor] = [self.point(x1, y1), self.point(x2, y2), self.point(x3, y3)];
|
||||
self.current_subpath.last_manipulator_group_mut().unwrap().out_handle = Some(handle1);
|
||||
self.current_subpath
|
||||
.push_manipulator_group(ManipulatorGroup::new_with_id(anchor, Some(handle2), None, self.id.next_id()));
|
||||
}
|
||||
|
||||
fn close(&mut self) {
|
||||
self.current_subpath.set_closed(true);
|
||||
self.glyph_subpaths.push(std::mem::replace(&mut self.current_subpath, Subpath::new(Vec::new(), false)));
|
||||
}
|
||||
}
|
||||
127
node-graph/nodes/text/src/text_context.rs
Normal file
127
node-graph/nodes/text/src/text_context.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
use super::{Font, FontCache, TypesettingConfig};
|
||||
use core::cell::RefCell;
|
||||
use core_types::table::Table;
|
||||
use glam::DVec2;
|
||||
use parley::fontique::{Blob, FamilyId, FontInfo};
|
||||
use parley::{AlignmentOptions, FontContext, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty};
|
||||
use std::collections::HashMap;
|
||||
use vector_types::Vector;
|
||||
|
||||
use super::path_builder::PathBuilder;
|
||||
|
||||
thread_local! {
|
||||
static THREAD_TEXT: RefCell<TextContext> = RefCell::new(TextContext::default());
|
||||
}
|
||||
|
||||
/// Unified thread-local text processing context that combines font and layout management
|
||||
/// for efficient text rendering operations.
|
||||
#[derive(Default)]
|
||||
pub struct TextContext {
|
||||
font_context: FontContext,
|
||||
layout_context: LayoutContext<()>,
|
||||
/// Cached font metadata for performance optimization
|
||||
font_info_cache: HashMap<Font, (FamilyId, FontInfo)>,
|
||||
}
|
||||
|
||||
impl TextContext {
|
||||
/// Access the thread-local TextContext instance for text processing operations
|
||||
pub fn with_thread_local<F, R>(f: F) -> R
|
||||
where
|
||||
F: FnOnce(&mut TextContext) -> R,
|
||||
{
|
||||
THREAD_TEXT.with_borrow_mut(f)
|
||||
}
|
||||
|
||||
/// Resolve a font and return its data as a Blob if available
|
||||
fn resolve_font_data<'a>(&self, font: &'a Font, font_cache: &'a FontCache) -> Option<(Blob<u8>, &'a Font)> {
|
||||
font_cache.get_blob(font)
|
||||
}
|
||||
|
||||
/// Get or cache font information for a given font
|
||||
fn get_font_info(&mut self, font: &Font, font_data: &Blob<u8>) -> Option<(String, FontInfo)> {
|
||||
// Check if we already have the font info cached
|
||||
if let Some((family_id, font_info)) = self.font_info_cache.get(font)
|
||||
&& let Some(family_name) = self.font_context.collection.family_name(*family_id)
|
||||
{
|
||||
return Some((family_name.to_string(), font_info.clone()));
|
||||
}
|
||||
|
||||
// Register the font and cache the info
|
||||
let families = self.font_context.collection.register_fonts(font_data.clone(), None);
|
||||
|
||||
families.first().and_then(|(family_id, fonts_info)| {
|
||||
fonts_info.first().and_then(|font_info| {
|
||||
self.font_context.collection.family_name(*family_id).map(|family_name| {
|
||||
// Cache the font info for future use
|
||||
self.font_info_cache.insert(font.clone(), (*family_id, font_info.clone()));
|
||||
(family_name.to_string(), font_info.clone())
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a text layout using the specified font and typesetting configuration
|
||||
fn layout_text(&mut self, text: &str, font: &Font, font_cache: &FontCache, typesetting: TypesettingConfig) -> Option<Layout<()>> {
|
||||
// Note that the actual_font may not be the desired font if that font is not yet loaded.
|
||||
// It is important not to cache the default font under the name of another font.
|
||||
let (font_data, actual_font) = self.resolve_font_data(font, font_cache)?;
|
||||
let (font_family, font_info) = self.get_font_info(actual_font, &font_data)?;
|
||||
|
||||
const DISPLAY_SCALE: f32 = 1.;
|
||||
let mut builder = self.layout_context.ranged_builder(&mut self.font_context, text, DISPLAY_SCALE, false);
|
||||
|
||||
builder.push_default(StyleProperty::FontSize(typesetting.font_size as f32));
|
||||
builder.push_default(StyleProperty::LetterSpacing(typesetting.character_spacing as f32));
|
||||
builder.push_default(StyleProperty::FontStack(parley::FontStack::Single(parley::FontFamily::Named(std::borrow::Cow::Owned(font_family)))));
|
||||
builder.push_default(StyleProperty::FontWeight(font_info.weight()));
|
||||
builder.push_default(StyleProperty::FontStyle(font_info.style()));
|
||||
builder.push_default(StyleProperty::FontWidth(font_info.width()));
|
||||
builder.push_default(LineHeight::FontSizeRelative(typesetting.line_height_ratio as f32));
|
||||
|
||||
let mut layout: Layout<()> = builder.build(text);
|
||||
|
||||
layout.break_all_lines(typesetting.max_width.map(|mw| mw as f32));
|
||||
layout.align(typesetting.max_width.map(|max_w| max_w as f32), typesetting.align.into(), AlignmentOptions::default());
|
||||
|
||||
Some(layout)
|
||||
}
|
||||
|
||||
/// Convert text to vector paths using the specified font and typesetting configuration
|
||||
pub fn to_path<Upstream: Default + 'static>(&mut self, text: &str, font: &Font, font_cache: &FontCache, typesetting: TypesettingConfig, per_glyph_instances: bool) -> Table<Vector<Upstream>> {
|
||||
let Some(layout) = self.layout_text(text, font, font_cache, typesetting) else {
|
||||
return Table::new_from_element(Vector::default());
|
||||
};
|
||||
|
||||
let mut path_builder = PathBuilder::new(per_glyph_instances, layout.scale() as f64);
|
||||
|
||||
for line in layout.lines() {
|
||||
for item in line.items() {
|
||||
if let PositionedLayoutItem::GlyphRun(glyph_run) = item {
|
||||
path_builder.render_glyph_run(&glyph_run, typesetting.tilt, per_glyph_instances);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
path_builder.finalize()
|
||||
}
|
||||
|
||||
/// Calculate the bounding box of text using the specified font and typesetting configuration
|
||||
pub fn bounding_box(&mut self, text: &str, font: &Font, font_cache: &FontCache, typesetting: TypesettingConfig, for_clipping_test: bool) -> DVec2 {
|
||||
if !for_clipping_test && let (Some(max_height), Some(max_width)) = (typesetting.max_height, typesetting.max_width) {
|
||||
return DVec2::new(max_width, max_height);
|
||||
}
|
||||
|
||||
let Some(layout) = self.layout_text(text, font, font_cache, typesetting) else {
|
||||
return DVec2::ZERO;
|
||||
};
|
||||
|
||||
DVec2::new(layout.full_width() as f64, layout.height() as f64)
|
||||
}
|
||||
|
||||
/// Check if text lines are being clipped due to height constraints
|
||||
pub fn lines_clipping(&mut self, text: &str, font: &Font, font_cache: &FontCache, typesetting: TypesettingConfig) -> bool {
|
||||
let Some(max_height) = typesetting.max_height else { return false };
|
||||
let bounds = self.bounding_box(text, font, font_cache, typesetting, true);
|
||||
max_height < bounds.y
|
||||
}
|
||||
}
|
||||
23
node-graph/nodes/text/src/to_path.rs
Normal file
23
node-graph/nodes/text/src/to_path.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use super::text_context::TextContext;
|
||||
use super::{Font, FontCache, TypesettingConfig};
|
||||
use core_types::table::Table;
|
||||
use glam::DVec2;
|
||||
use parley::fontique::Blob;
|
||||
use std::sync::Arc;
|
||||
use vector_types::Vector;
|
||||
|
||||
pub fn to_path<Upstream: Default + 'static>(text: &str, font: &Font, font_cache: &FontCache, typesetting: TypesettingConfig, per_glyph_instances: bool) -> Table<Vector<Upstream>> {
|
||||
TextContext::with_thread_local(|ctx| ctx.to_path(text, font, font_cache, typesetting, per_glyph_instances))
|
||||
}
|
||||
|
||||
pub fn bounding_box(text: &str, font: &Font, font_cache: &FontCache, typesetting: TypesettingConfig, for_clipping_test: bool) -> DVec2 {
|
||||
TextContext::with_thread_local(|ctx| ctx.bounding_box(text, font, font_cache, typesetting, for_clipping_test))
|
||||
}
|
||||
|
||||
pub fn load_font(data: &[u8]) -> Blob<u8> {
|
||||
Blob::new(Arc::new(data.to_vec()))
|
||||
}
|
||||
|
||||
pub fn lines_clipping(text: &str, font: &Font, font_cache: &FontCache, typesetting: TypesettingConfig) -> bool {
|
||||
TextContext::with_thread_local(|ctx| ctx.lines_clipping(text, font, font_cache, typesetting))
|
||||
}
|
||||
23
node-graph/nodes/transform/Cargo.toml
Normal file
23
node-graph/nodes/transform/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "transform-nodes"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Transform operation nodes for Graphene"
|
||||
authors = ["Graphite Authors <contact@graphite.rs>"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[features]
|
||||
default = ["serde"]
|
||||
|
||||
[dependencies]
|
||||
# Local dependencies
|
||||
core-types = { workspace = true }
|
||||
vector-types = { workspace = true }
|
||||
graphic-types = { workspace = true }
|
||||
node-macro = { workspace = true }
|
||||
|
||||
# Workspace dependencies
|
||||
glam = { workspace = true }
|
||||
|
||||
# Optional workspace dependencies
|
||||
serde = { workspace = true, optional = true }
|
||||
7
node-graph/nodes/transform/src/lib.rs
Normal file
7
node-graph/nodes/transform/src/lib.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod transform_nodes;
|
||||
|
||||
// Re-export for convenience
|
||||
pub use core_types as gcore;
|
||||
pub use graphic_types;
|
||||
pub use transform_nodes::*;
|
||||
pub use vector_types;
|
||||
106
node-graph/nodes/transform/src/transform_nodes.rs
Normal file
106
node-graph/nodes/transform/src/transform_nodes.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
use core::f64;
|
||||
use core_types::color::Color;
|
||||
use core_types::table::Table;
|
||||
use core_types::transform::{ApplyTransform, Transform};
|
||||
use core_types::{CloneVarArgs, Context, Ctx, ExtractAll, InjectFootprint, ModifyFootprint, OwnedContextImpl};
|
||||
use glam::{DAffine2, DVec2};
|
||||
use graphic_types::Graphic;
|
||||
use graphic_types::Vector;
|
||||
use graphic_types::raster_types::{CPU, GPU, Raster};
|
||||
use vector_types::GradientStops;
|
||||
|
||||
/// Applies the specified transform to the input value, which may be a graphic type or another transform.
|
||||
#[node_macro::node(category(""))]
|
||||
async fn transform<T: ApplyTransform + 'n + 'static>(
|
||||
ctx: impl Ctx + CloneVarArgs + ExtractAll + ModifyFootprint,
|
||||
#[implementations(
|
||||
Context -> DAffine2,
|
||||
Context -> DVec2,
|
||||
Context -> Table<Vector>,
|
||||
Context -> Table<Graphic>,
|
||||
Context -> Table<Raster<CPU>>,
|
||||
Context -> Table<Raster<GPU>>,
|
||||
Context -> Table<Color>,
|
||||
Context -> Table<GradientStops>,
|
||||
)]
|
||||
value: impl Node<Context<'static>, Output = T>,
|
||||
translation: DVec2,
|
||||
rotation: f64,
|
||||
scale: DVec2,
|
||||
skew: DVec2,
|
||||
) -> T {
|
||||
let trs = DAffine2::from_scale_angle_translation(scale, rotation.to_radians(), translation);
|
||||
let skew = DAffine2::from_cols_array(&[1., skew.y.to_radians().tan(), skew.x.to_radians().tan(), 1., 0., 0.]);
|
||||
let matrix = trs * skew;
|
||||
|
||||
let footprint = ctx.try_footprint().copied();
|
||||
|
||||
let mut ctx = OwnedContextImpl::from(ctx);
|
||||
if let Some(mut footprint) = footprint {
|
||||
footprint.apply_transform(&matrix);
|
||||
ctx = ctx.with_footprint(footprint);
|
||||
}
|
||||
|
||||
let mut transform_target = value.eval(ctx.into_context()).await;
|
||||
|
||||
transform_target.left_apply_transform(&matrix);
|
||||
|
||||
transform_target
|
||||
}
|
||||
|
||||
/// Overwrites the transform of each element in the input table with the specified transform.
|
||||
#[node_macro::node(category(""))]
|
||||
fn replace_transform<Data, TransformInput: Transform>(
|
||||
_: impl Ctx + InjectFootprint,
|
||||
#[implementations(Table<Vector>, Table<Raster<CPU>>, Table<Graphic>, Table<Color>, Table<GradientStops>)] mut data: Table<Data>,
|
||||
#[implementations(DAffine2)] transform: TransformInput,
|
||||
) -> Table<Data> {
|
||||
for data_transform in data.iter_mut() {
|
||||
*data_transform.transform = transform.transform();
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
// TODO: Figure out how this node should behave once #2982 is implemented.
|
||||
/// Obtains the transform of the first element in the input table, if present.
|
||||
#[node_macro::node(category("Math: Transform"), path(core_types::vector))]
|
||||
async fn extract_transform<T>(
|
||||
_: impl Ctx,
|
||||
#[implementations(
|
||||
Table<Graphic>,
|
||||
Table<Vector>,
|
||||
Table<Raster<CPU>>,
|
||||
Table<Raster<GPU>>,
|
||||
Table<Color>,
|
||||
Table<GradientStops>,
|
||||
)]
|
||||
vector: Table<T>,
|
||||
) -> DAffine2 {
|
||||
vector.iter().next().map(|row| *row.transform).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Produces the inverse of the input transform, which is the transform that undoes the effect of the original transform.
|
||||
#[node_macro::node(category("Math: Transform"))]
|
||||
fn invert_transform(_: impl Ctx, transform: DAffine2) -> DAffine2 {
|
||||
transform.inverse()
|
||||
}
|
||||
|
||||
/// Extracts the translation component from the input transform.
|
||||
#[node_macro::node(category("Math: Transform"))]
|
||||
fn decompose_translation(_: impl Ctx, transform: DAffine2) -> DVec2 {
|
||||
transform.translation
|
||||
}
|
||||
|
||||
/// Extracts the rotation component (in degrees) from the input transform.
|
||||
/// This, together with the "Decompose Scale" node, also may jointly represent any shear component in the original transform.
|
||||
#[node_macro::node(category("Math: Transform"))]
|
||||
fn decompose_rotation(_: impl Ctx, transform: DAffine2) -> f64 {
|
||||
transform.decompose_rotation().to_degrees()
|
||||
}
|
||||
|
||||
/// Extracts the scale component from the input transform.
|
||||
/// This, together with the "Decompose Rotation" node, also may jointly represent any shear component in the original transform.
|
||||
#[node_macro::node(category("Math: Transform"))]
|
||||
fn decompose_scale(_: impl Ctx, transform: DAffine2) -> DVec2 {
|
||||
transform.decompose_scale()
|
||||
}
|
||||
33
node-graph/nodes/vector/Cargo.toml
Normal file
33
node-graph/nodes/vector/Cargo.toml
Normal file
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "vector-nodes"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Vector operation nodes for Graphene"
|
||||
authors = ["Graphite Authors <contact@graphite.rs>"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[features]
|
||||
default = ["serde"]
|
||||
|
||||
[dependencies]
|
||||
# Local dependencies
|
||||
core-types = { workspace = true }
|
||||
vector-types = { workspace = true }
|
||||
graphic-types = { workspace = true }
|
||||
node-macro = { workspace = true }
|
||||
|
||||
# Workspace dependencies
|
||||
dyn-any = { workspace = true }
|
||||
glam = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
rustc-hash = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
# Optional workspace dependencies
|
||||
serde = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
graphene-core = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt"] }
|
||||
futures = { workspace = true }
|
||||
352
node-graph/nodes/vector/src/generator_nodes.rs
Normal file
352
node-graph/nodes/vector/src/generator_nodes.rs
Normal file
@@ -0,0 +1,352 @@
|
||||
use core_types::Ctx;
|
||||
use core_types::registry::types::{Angle, PixelSize};
|
||||
use core_types::table::Table;
|
||||
use glam::DVec2;
|
||||
use graphic_types::Vector;
|
||||
use vector_types::subpath;
|
||||
use vector_types::vector::misc::{ArcType, AsU64, GridType};
|
||||
use vector_types::vector::misc::{HandleId, SpiralType};
|
||||
use vector_types::vector::{PointId, SegmentId, StrokeId};
|
||||
|
||||
trait CornerRadius {
|
||||
fn generate(self, size: DVec2, clamped: bool) -> Table<Vector>;
|
||||
}
|
||||
impl CornerRadius for f64 {
|
||||
fn generate(self, size: DVec2, clamped: bool) -> Table<Vector> {
|
||||
let clamped_radius = if clamped { self.clamp(0., size.x.min(size.y).max(0.) / 2.) } else { self };
|
||||
Table::new_from_element(Vector::from_subpath(subpath::Subpath::new_rounded_rect(size / -2., size / 2., [clamped_radius; 4])))
|
||||
}
|
||||
}
|
||||
impl CornerRadius for [f64; 4] {
|
||||
fn generate(self, size: DVec2, clamped: bool) -> Table<Vector> {
|
||||
let clamped_radius = if clamped {
|
||||
// Algorithm follows the CSS spec: <https://drafts.csswg.org/css-backgrounds/#corner-overlap>
|
||||
|
||||
let mut scale_factor: f64 = 1.;
|
||||
for i in 0..4 {
|
||||
let side_length = if i % 2 == 0 { size.x } else { size.y };
|
||||
let adjacent_corner_radius_sum = self[i] + self[(i + 1) % 4];
|
||||
if side_length < adjacent_corner_radius_sum {
|
||||
scale_factor = scale_factor.min(side_length / adjacent_corner_radius_sum);
|
||||
}
|
||||
}
|
||||
self.map(|x| x * scale_factor)
|
||||
} else {
|
||||
self
|
||||
};
|
||||
Table::new_from_element(Vector::from_subpath(subpath::Subpath::new_rounded_rect(size / -2., size / 2., clamped_radius)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates a circle shape with a chosen radius.
|
||||
#[node_macro::node(category("Vector: Shape"))]
|
||||
fn circle(
|
||||
_: impl Ctx,
|
||||
_primary: (),
|
||||
#[unit(" px")]
|
||||
#[default(50.)]
|
||||
radius: f64,
|
||||
) -> Table<Vector> {
|
||||
let radius = radius.abs();
|
||||
Table::new_from_element(Vector::from_subpath(subpath::Subpath::new_ellipse(DVec2::splat(-radius), DVec2::splat(radius))))
|
||||
}
|
||||
|
||||
/// Generates an arc shape forming a portion of a circle which may be open, closed, or a pie slice.
|
||||
#[node_macro::node(category("Vector: Shape"))]
|
||||
fn arc(
|
||||
_: impl Ctx,
|
||||
_primary: (),
|
||||
#[unit(" px")]
|
||||
#[default(50.)]
|
||||
radius: f64,
|
||||
start_angle: Angle,
|
||||
#[default(270.)]
|
||||
#[range((0., 360.))]
|
||||
sweep_angle: Angle,
|
||||
arc_type: ArcType,
|
||||
) -> Table<Vector> {
|
||||
Table::new_from_element(Vector::from_subpath(subpath::Subpath::new_arc(
|
||||
radius,
|
||||
start_angle / 360. * std::f64::consts::TAU,
|
||||
sweep_angle / 360. * std::f64::consts::TAU,
|
||||
match arc_type {
|
||||
ArcType::Open => subpath::ArcType::Open,
|
||||
ArcType::Closed => subpath::ArcType::Closed,
|
||||
ArcType::PieSlice => subpath::ArcType::PieSlice,
|
||||
},
|
||||
)))
|
||||
}
|
||||
|
||||
/// Generates a spiral shape that winds from an inner to an outer radius.
|
||||
#[node_macro::node(category("Vector: Shape"), properties("spiral_properties"))]
|
||||
fn spiral(
|
||||
_: impl Ctx,
|
||||
_primary: (),
|
||||
spiral_type: SpiralType,
|
||||
#[default(5.)] turns: f64,
|
||||
#[default(0.)] start_angle: f64,
|
||||
#[default(0.)] inner_radius: f64,
|
||||
#[default(25)] outer_radius: f64,
|
||||
#[default(90.)] angular_resolution: f64,
|
||||
) -> Table<Vector> {
|
||||
Table::new_from_element(Vector::from_subpath(subpath::Subpath::new_spiral(
|
||||
inner_radius,
|
||||
outer_radius,
|
||||
turns,
|
||||
start_angle.to_radians(),
|
||||
angular_resolution.to_radians(),
|
||||
spiral_type,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Generates an ellipse shape (an oval or stretched circle) with the chosen radii.
|
||||
#[node_macro::node(category("Vector: Shape"))]
|
||||
fn ellipse(
|
||||
_: impl Ctx,
|
||||
_primary: (),
|
||||
#[unit(" px")]
|
||||
#[default(50)]
|
||||
radius_x: f64,
|
||||
#[unit(" px")]
|
||||
#[default(25)]
|
||||
radius_y: f64,
|
||||
) -> Table<Vector> {
|
||||
let radius = DVec2::new(radius_x, radius_y);
|
||||
let corner1 = -radius;
|
||||
let corner2 = radius;
|
||||
|
||||
let mut ellipse = Vector::from_subpath(subpath::Subpath::new_ellipse(corner1, corner2));
|
||||
|
||||
let len = ellipse.segment_domain.ids().len();
|
||||
for i in 0..len {
|
||||
ellipse
|
||||
.colinear_manipulators
|
||||
.push([HandleId::end(ellipse.segment_domain.ids()[i]), HandleId::primary(ellipse.segment_domain.ids()[(i + 1) % len])]);
|
||||
}
|
||||
|
||||
Table::new_from_element(ellipse)
|
||||
}
|
||||
|
||||
/// Generates a rectangle shape with the chosen width and height. It may also have rounded corners if desired.
|
||||
#[node_macro::node(category("Vector: Shape"), properties("rectangle_properties"))]
|
||||
fn rectangle<T: CornerRadius>(
|
||||
_: impl Ctx,
|
||||
_primary: (),
|
||||
#[unit(" px")]
|
||||
#[default(100)]
|
||||
width: f64,
|
||||
#[unit(" px")]
|
||||
#[default(100)]
|
||||
height: f64,
|
||||
_individual_corner_radii: bool, // TODO: Move this to the bottom once we have a migration capability
|
||||
#[implementations(f64, [f64; 4])] corner_radius: T,
|
||||
#[default(true)] clamped: bool,
|
||||
) -> Table<Vector> {
|
||||
corner_radius.generate(DVec2::new(width, height), clamped)
|
||||
}
|
||||
|
||||
/// Generates an regular polygon shape like a triangle, square, pentagon, hexagon, heptagon, octagon, or any higher n-gon.
|
||||
#[node_macro::node(category("Vector: Shape"))]
|
||||
fn regular_polygon<T: AsU64>(
|
||||
_: impl Ctx,
|
||||
_primary: (),
|
||||
#[default(6)]
|
||||
#[hard_min(3.)]
|
||||
#[implementations(u32, u64, f64)]
|
||||
sides: T,
|
||||
#[unit(" px")]
|
||||
#[default(50)]
|
||||
radius: f64,
|
||||
) -> Table<Vector> {
|
||||
let points = sides.as_u64();
|
||||
let radius: f64 = radius * 2.;
|
||||
Table::new_from_element(Vector::from_subpath(subpath::Subpath::new_regular_polygon(DVec2::splat(-radius), points, radius)))
|
||||
}
|
||||
|
||||
/// Generates an n-pointed star shape with inner and outer points at chosen radii from the center.
|
||||
#[node_macro::node(category("Vector: Shape"))]
|
||||
fn star<T: AsU64>(
|
||||
_: impl Ctx,
|
||||
_primary: (),
|
||||
#[default(5)]
|
||||
#[hard_min(2.)]
|
||||
#[implementations(u32, u64, f64)]
|
||||
sides: T,
|
||||
#[unit(" px")]
|
||||
#[default(50)]
|
||||
radius_1: f64,
|
||||
#[unit(" px")]
|
||||
#[default(25)]
|
||||
radius_2: f64,
|
||||
) -> Table<Vector> {
|
||||
let points = sides.as_u64();
|
||||
let diameter: f64 = radius_1 * 2.;
|
||||
let inner_diameter = radius_2 * 2.;
|
||||
|
||||
Table::new_from_element(Vector::from_subpath(subpath::Subpath::new_star_polygon(DVec2::splat(-diameter), points, diameter, inner_diameter)))
|
||||
}
|
||||
|
||||
/// Generates a line with endpoints at the two chosen coordinates.
|
||||
#[node_macro::node(category("Vector: Shape"))]
|
||||
fn line(
|
||||
_: impl Ctx,
|
||||
_primary: (),
|
||||
/// Coordinate of the line's initial endpoint.
|
||||
#[default(0., 0.)]
|
||||
start: PixelSize,
|
||||
/// Coordinate of the line's terminal endpoint.
|
||||
#[default(100., 100.)]
|
||||
end: PixelSize,
|
||||
) -> Table<Vector> {
|
||||
Table::new_from_element(Vector::from_subpath(subpath::Subpath::new_line(start, end)))
|
||||
}
|
||||
|
||||
trait GridSpacing {
|
||||
fn as_dvec2(&self) -> DVec2;
|
||||
}
|
||||
impl GridSpacing for f64 {
|
||||
fn as_dvec2(&self) -> DVec2 {
|
||||
DVec2::splat(*self)
|
||||
}
|
||||
}
|
||||
impl GridSpacing for DVec2 {
|
||||
fn as_dvec2(&self) -> DVec2 {
|
||||
*self
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates a rectangular or isometric grid with the chosen number of columns and rows. Line segments connect the points, forming a vector mesh.
|
||||
#[node_macro::node(category("Vector: Shape"), properties("grid_properties"))]
|
||||
fn grid<T: GridSpacing>(
|
||||
_: impl Ctx,
|
||||
_primary: (),
|
||||
grid_type: GridType,
|
||||
#[unit(" px")]
|
||||
#[hard_min(0.)]
|
||||
#[default(10)]
|
||||
#[implementations(f64, DVec2)]
|
||||
spacing: T,
|
||||
#[default(10)] columns: u32,
|
||||
#[default(10)] rows: u32,
|
||||
#[default(30., 30.)] angles: DVec2,
|
||||
) -> Table<Vector> {
|
||||
let (x_spacing, y_spacing) = spacing.as_dvec2().into();
|
||||
let (angle_a, angle_b) = angles.into();
|
||||
|
||||
let mut vector = Vector::default();
|
||||
let mut segment_id = SegmentId::ZERO;
|
||||
let mut point_id = PointId::ZERO;
|
||||
|
||||
match grid_type {
|
||||
GridType::Rectangular => {
|
||||
// Create rectangular grid points and connect them with line segments
|
||||
for y in 0..rows {
|
||||
for x in 0..columns {
|
||||
// Add current point to the grid
|
||||
let current_index = vector.point_domain.ids().len();
|
||||
vector.point_domain.push(point_id.next_id(), DVec2::new(x_spacing * x as f64, y_spacing * y as f64));
|
||||
|
||||
// Helper function to connect points with line segments
|
||||
let mut push_segment = |to_index: Option<usize>| {
|
||||
if let Some(other_index) = to_index {
|
||||
vector
|
||||
.segment_domain
|
||||
.push(segment_id.next_id(), other_index, current_index, subpath::BezierHandles::Linear, StrokeId::ZERO);
|
||||
}
|
||||
};
|
||||
|
||||
// Connect to the point to the left (horizontal connection)
|
||||
push_segment((x > 0).then(|| current_index - 1));
|
||||
|
||||
// Connect to the point above (vertical connection)
|
||||
push_segment(current_index.checked_sub(columns as usize));
|
||||
}
|
||||
}
|
||||
}
|
||||
GridType::Isometric => {
|
||||
// Calculate isometric grid spacing based on angles
|
||||
let tan_a = angle_a.to_radians().tan();
|
||||
let tan_b = angle_b.to_radians().tan();
|
||||
let spacing = DVec2::new(y_spacing / (tan_a + tan_b), y_spacing);
|
||||
|
||||
// Create isometric grid points and connect them with line segments
|
||||
for y in 0..rows {
|
||||
for x in 0..columns {
|
||||
// Add current point to the grid with offset for odd columns
|
||||
let current_index = vector.point_domain.ids().len();
|
||||
|
||||
let a_angles_eaten = x.div_ceil(2) as f64;
|
||||
let b_angles_eaten = (x / 2) as f64;
|
||||
|
||||
let offset_y_fraction = b_angles_eaten * tan_b - a_angles_eaten * tan_a;
|
||||
|
||||
let position = DVec2::new(spacing.x * x as f64, spacing.y * y as f64 + offset_y_fraction * spacing.x);
|
||||
vector.point_domain.push(point_id.next_id(), position);
|
||||
|
||||
// Helper function to connect points with line segments
|
||||
let mut push_segment = |to_index: Option<usize>| {
|
||||
if let Some(other_index) = to_index {
|
||||
vector
|
||||
.segment_domain
|
||||
.push(segment_id.next_id(), other_index, current_index, subpath::BezierHandles::Linear, StrokeId::ZERO);
|
||||
}
|
||||
};
|
||||
|
||||
// Connect to the point to the left
|
||||
push_segment((x > 0).then(|| current_index - 1));
|
||||
|
||||
// Connect to the point directly above
|
||||
push_segment(current_index.checked_sub(columns as usize));
|
||||
|
||||
// Additional diagonal connections for odd columns (creates hexagonal pattern)
|
||||
if x % 2 == 1 {
|
||||
// Connect to the point diagonally up-right (if not at right edge)
|
||||
push_segment(current_index.checked_sub(columns as usize - 1).filter(|_| x + 1 < columns));
|
||||
|
||||
// Connect to the point diagonally up-left
|
||||
push_segment(current_index.checked_sub(columns as usize + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Table::new_from_element(vector)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn isometric_grid_test() {
|
||||
// Doesn't crash with weird angles
|
||||
grid((), (), GridType::Isometric, 0., 5, 5, (0., 0.).into());
|
||||
grid((), (), GridType::Isometric, 90., 5, 5, (90., 90.).into());
|
||||
|
||||
// Works properly
|
||||
let grid = grid((), (), GridType::Isometric, 10., 5, 5, (30., 30.).into());
|
||||
assert_eq!(grid.iter().next().unwrap().element.point_domain.ids().len(), 5 * 5);
|
||||
assert_eq!(grid.iter().next().unwrap().element.segment_bezier_iter().count(), 4 * 5 + 4 * 9);
|
||||
for (_, bezier, _, _) in grid.iter().next().unwrap().element.segment_bezier_iter() {
|
||||
assert_eq!(bezier.handles, subpath::BezierHandles::Linear);
|
||||
assert!(
|
||||
((bezier.start - bezier.end).length() - 10.).abs() < 1e-5,
|
||||
"Length of {} should be 10",
|
||||
(bezier.start - bezier.end).length()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skew_isometric_grid_test() {
|
||||
let grid = grid((), (), GridType::Isometric, 10., 5, 5, (40., 30.).into());
|
||||
assert_eq!(grid.iter().next().unwrap().element.point_domain.ids().len(), 5 * 5);
|
||||
assert_eq!(grid.iter().next().unwrap().element.segment_bezier_iter().count(), 4 * 5 + 4 * 9);
|
||||
for (_, bezier, _, _) in grid.iter().next().unwrap().element.segment_bezier_iter() {
|
||||
assert_eq!(bezier.handles, subpath::BezierHandles::Linear);
|
||||
let vector = bezier.start - bezier.end;
|
||||
let angle = (vector.angle_to(DVec2::X).to_degrees() + 180.) % 180.;
|
||||
assert!([90., 150., 40.].into_iter().any(|target| (target - angle).abs() < 1e-10), "unexpected angle of {angle}")
|
||||
}
|
||||
}
|
||||
}
|
||||
170
node-graph/nodes/vector/src/instance.rs
Normal file
170
node-graph/nodes/vector/src/instance.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
use core_types::Color;
|
||||
use core_types::table::{Table, TableRowRef};
|
||||
use core_types::{CloneVarArgs, Context, Ctx, ExtractAll, ExtractIndex, ExtractVarArgs, InjectVarArgs, OwnedContextImpl};
|
||||
use glam::DVec2;
|
||||
use graphic_types::Graphic;
|
||||
use graphic_types::Vector;
|
||||
use graphic_types::raster_types::{CPU, Raster};
|
||||
use vector_types::GradientStops;
|
||||
|
||||
use log::*;
|
||||
|
||||
#[repr(transparent)]
|
||||
#[derive(dyn_any::DynAny)]
|
||||
struct HashableDVec2(DVec2);
|
||||
|
||||
impl std::hash::Hash for HashableDVec2 {
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
self.0.x.to_bits().hash(state);
|
||||
self.0.y.to_bits().hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
#[node_macro::node(name("Instance on Points"), category("Instancing"), path(core_types::vector))]
|
||||
async fn instance_on_points<T: Into<Graphic> + Default + Send + Clone + 'static>(
|
||||
ctx: impl ExtractAll + CloneVarArgs + Sync + Ctx + InjectVarArgs,
|
||||
points: Table<Vector>,
|
||||
#[implementations(
|
||||
Context -> Table<Graphic>,
|
||||
Context -> Table<Vector>,
|
||||
Context -> Table<Raster<CPU>>,
|
||||
Context -> Table<Color>,
|
||||
Context -> Table<GradientStops>,
|
||||
)]
|
||||
instance: impl Node<'n, Context<'static>, Output = Table<T>>,
|
||||
reverse: bool,
|
||||
) -> Table<T> {
|
||||
let mut result_table = Table::new();
|
||||
|
||||
for TableRowRef { element: points, transform, .. } in points.iter() {
|
||||
let mut iteration = async |index, point| {
|
||||
let transformed_point = transform.transform_point2(point);
|
||||
|
||||
let new_ctx = OwnedContextImpl::from(ctx.clone()).with_index(index).with_vararg(Box::new(HashableDVec2(transformed_point)));
|
||||
let generated_instance = instance.eval(new_ctx.into_context()).await;
|
||||
|
||||
for mut generated_row in generated_instance.into_iter() {
|
||||
generated_row.transform.translation = transformed_point;
|
||||
result_table.push(generated_row);
|
||||
}
|
||||
};
|
||||
|
||||
let range = points.point_domain.positions().iter().enumerate();
|
||||
if reverse {
|
||||
for (index, &point) in range.rev() {
|
||||
iteration(index, point).await;
|
||||
}
|
||||
} else {
|
||||
for (index, &point) in range {
|
||||
iteration(index, point).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result_table
|
||||
}
|
||||
|
||||
#[node_macro::node(category("Instancing"), path(core_types::vector))]
|
||||
async fn instance_repeat<T: Into<Graphic> + Default + Send + Clone + 'static>(
|
||||
ctx: impl ExtractAll + CloneVarArgs + Ctx,
|
||||
#[implementations(
|
||||
Context -> Table<Graphic>,
|
||||
Context -> Table<Vector>,
|
||||
Context -> Table<Raster<CPU>>,
|
||||
Context -> Table<Color>,
|
||||
Context -> Table<GradientStops>,
|
||||
)]
|
||||
instance: impl Node<'n, Context<'static>, Output = Table<T>>,
|
||||
#[default(1)] count: u64,
|
||||
reverse: bool,
|
||||
) -> Table<T> {
|
||||
let count = count.max(1) as usize;
|
||||
|
||||
let mut result_table = Table::new();
|
||||
|
||||
for index in 0..count {
|
||||
let index = if reverse { count - index - 1 } else { index };
|
||||
|
||||
let new_ctx = OwnedContextImpl::from(ctx.clone()).with_index(index);
|
||||
let generated_instance = instance.eval(new_ctx.into_context()).await;
|
||||
|
||||
for generated_row in generated_instance.into_iter() {
|
||||
result_table.push(generated_row);
|
||||
}
|
||||
}
|
||||
|
||||
result_table
|
||||
}
|
||||
|
||||
#[node_macro::node(category("Instancing"), path(core_types::vector))]
|
||||
async fn instance_position(ctx: impl Ctx + ExtractVarArgs) -> DVec2 {
|
||||
match ctx.vararg(0).map(|dynamic| dynamic.downcast_ref::<HashableDVec2>()) {
|
||||
Ok(Some(position)) => return position.0,
|
||||
Ok(_) => warn!("Extracted value of incorrect type"),
|
||||
Err(e) => warn!("Cannot extract position vararg: {e:?}"),
|
||||
}
|
||||
Default::default()
|
||||
}
|
||||
|
||||
// TODO: Return u32, u64, or usize instead of f64 after #1621 is resolved and has allowed us to implement automatic type conversion in the node graph for nodes with generic type inputs.
|
||||
// TODO: (Currently automatic type conversion only works for concrete types, via the Graphene preprocessor and not the full Graphene type system.)
|
||||
#[node_macro::node(category("Instancing"), path(core_types::vector))]
|
||||
async fn instance_index(ctx: impl Ctx + ExtractIndex, _primary: (), loop_level: u32) -> f64 {
|
||||
let Some(index_iter) = ctx.try_index() else { return 0. };
|
||||
let mut last = 0;
|
||||
for (i, index) in index_iter.enumerate() {
|
||||
if i == loop_level as usize {
|
||||
return index as f64;
|
||||
}
|
||||
last = index;
|
||||
}
|
||||
last as f64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::generator_nodes::RectangleNode;
|
||||
use core_types::Ctx;
|
||||
use core_types::Node;
|
||||
use glam::DVec2;
|
||||
use graphene_core::extract_xy::{ExtractXyNode, XY};
|
||||
use graphic_types::Vector;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use vector_types::subpath::Subpath;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FutureWrapperNode<T: Clone>(T);
|
||||
|
||||
impl<'i, I: Ctx, T: 'i + Clone + Send> Node<'i, I> for FutureWrapperNode<T> {
|
||||
type Output = Pin<Box<dyn Future<Output = T> + 'i + Send>>;
|
||||
fn eval(&'i self, _input: I) -> Self::Output {
|
||||
let value = self.0.clone();
|
||||
Box::pin(async move { value })
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn instance_on_points_test() {
|
||||
let owned = OwnedContextImpl::default().into_context();
|
||||
let rect = RectangleNode::new(
|
||||
FutureWrapperNode(()),
|
||||
ExtractXyNode::new(InstancePositionNode {}, FutureWrapperNode(XY::Y)),
|
||||
FutureWrapperNode(2_f64),
|
||||
FutureWrapperNode(false),
|
||||
FutureWrapperNode(0_f64),
|
||||
FutureWrapperNode(false),
|
||||
);
|
||||
|
||||
let positions = [DVec2::new(40., 20.), DVec2::ONE, DVec2::new(-42., 9.), DVec2::new(10., 345.)];
|
||||
let points = Table::new_from_element(Vector::from_subpath(Subpath::from_anchors_linear(positions, false)));
|
||||
let generated = super::instance_on_points(owned, points, &rect, false).await;
|
||||
assert_eq!(generated.len(), positions.len());
|
||||
for (position, generated_row) in positions.into_iter().zip(generated.iter()) {
|
||||
let bounds = generated_row.element.bounding_box_with_transform(*generated_row.transform).unwrap();
|
||||
assert!(position.abs_diff_eq((bounds[0] + bounds[1]) / 2., 1e-10));
|
||||
assert_eq!((bounds[1] - bounds[0]).x, position.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
16
node-graph/nodes/vector/src/lib.rs
Normal file
16
node-graph/nodes/vector/src/lib.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
pub mod generator_nodes;
|
||||
pub mod instance;
|
||||
pub mod vector_modification_nodes;
|
||||
mod vector_nodes;
|
||||
|
||||
#[macro_use]
|
||||
extern crate log;
|
||||
|
||||
// Re-export for convenience
|
||||
pub use core_types as gcore;
|
||||
pub use generator_nodes::*;
|
||||
pub use graphic_types;
|
||||
pub use instance::*;
|
||||
pub use vector_modification_nodes::*;
|
||||
pub use vector_nodes::*;
|
||||
pub use vector_types;
|
||||
44
node-graph/nodes/vector/src/vector_modification_nodes.rs
Normal file
44
node-graph/nodes/vector/src/vector_modification_nodes.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use core_types::Ctx;
|
||||
use core_types::table::Table;
|
||||
use core_types::uuid::NodeId;
|
||||
use glam::DAffine2;
|
||||
use graphic_types::Vector;
|
||||
use vector_types::vector::VectorModification;
|
||||
|
||||
/// Applies a differential modification to a vector path, associating changes made by the Pen and Path tools to indices of edited points and segments.
|
||||
#[node_macro::node(category(""))]
|
||||
async fn path_modify(_ctx: impl Ctx, mut vector: Table<Vector>, modification: Box<VectorModification>, node_path: Vec<NodeId>) -> Table<Vector> {
|
||||
use core_types::table::TableRow;
|
||||
|
||||
if vector.is_empty() {
|
||||
vector.push(TableRow::default());
|
||||
}
|
||||
let row = vector.get_mut(0).expect("push should give one item");
|
||||
modification.apply(row.element);
|
||||
|
||||
// Update the source node id
|
||||
let this_node_path = node_path.iter().rev().nth(1).copied();
|
||||
*row.source_node_id = row.source_node_id.or(this_node_path);
|
||||
|
||||
if vector.len() > 1 {
|
||||
warn!("The path modify ran on {} vector rows. Only the first can be modified.", vector.len());
|
||||
}
|
||||
vector
|
||||
}
|
||||
|
||||
/// Applies the vector path's local transformation to its geometry and resets the transform to the identity.
|
||||
#[node_macro::node(category("Vector"))]
|
||||
async fn apply_transform(_ctx: impl Ctx, mut vector: Table<Vector>) -> Table<Vector> {
|
||||
for row in vector.iter_mut() {
|
||||
let vector = row.element;
|
||||
let transform = *row.transform;
|
||||
|
||||
for (_, point) in vector.point_domain.positions_mut() {
|
||||
*point = transform.transform_point2(*point);
|
||||
}
|
||||
|
||||
*row.transform = DAffine2::IDENTITY;
|
||||
}
|
||||
|
||||
vector
|
||||
}
|
||||
2445
node-graph/nodes/vector/src/vector_nodes.rs
Normal file
2445
node-graph/nodes/vector/src/vector_nodes.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user