mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-16 23:08:05 +08:00
Merge origin/master into the async record refactor
Scaffolding merge for the reconcile; the final series to master is authored fresh. Rank plumbing resolves to our axis-IR model, the node macro and the LaneSource render walk stay ours, master's vector restructure and gradient vocabulary are adopted, and the paint and appearance adoption is deliberately deferred behind our fill and stroke markers.
This commit is contained in:
@@ -8,19 +8,24 @@ license = "MIT OR Apache-2.0"
|
||||
|
||||
[features]
|
||||
default = ["serde"]
|
||||
serde = ["dep:serde", "core-types/serde", "raster-types/serde", "raster-nodes/serde"]
|
||||
serde = ["dep:serde", "core-types/serde", "raster-types/serde"]
|
||||
|
||||
[dependencies]
|
||||
# Local dependencies
|
||||
dyn-any = { workspace = true }
|
||||
brush-types = { workspace = true }
|
||||
core-types = { workspace = true }
|
||||
graphene-hash = { workspace = true }
|
||||
raster-types = { workspace = true }
|
||||
raster-nodes = { workspace = true }
|
||||
graphic-types = { workspace = true }
|
||||
raster-types = { workspace = true, features = ["wgpu"] }
|
||||
wgpu-executor = { workspace = true }
|
||||
node-macro = { workspace = true }
|
||||
|
||||
# Workspace dependencies
|
||||
glam = { workspace = true }
|
||||
half = { workspace = true }
|
||||
bytemuck = { workspace = true }
|
||||
wgpu = { workspace = true }
|
||||
|
||||
# Optional workspace dependencies
|
||||
serde = { workspace = true, optional = true, features = ["derive"] }
|
||||
|
||||
8
node-graph/nodes/brush/src/basic_brush/consts.rs
Normal file
8
node-graph/nodes/brush/src/basic_brush/consts.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
pub(super) const SIGMA_CUTOFF: f32 = 3.;
|
||||
pub(super) const SIGMA_PER_DIAMETER: f64 = 1. / 4.;
|
||||
pub(super) const RIDGE_GAIN: f32 = 5.075688;
|
||||
|
||||
pub(super) const LUT_SIZE: u32 = 256;
|
||||
pub(super) const LUT_V_MAX: f64 = 7.5;
|
||||
pub(super) const LUT_T_MAX: f64 = 7.5;
|
||||
pub(super) const LUT_CACHE_SIZE: usize = 64;
|
||||
80
node-graph/nodes/brush/src/basic_brush/convert.rs
Normal file
80
node-graph/nodes/brush/src/basic_brush/convert.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
pub(super) struct Convert {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
}
|
||||
|
||||
impl Convert {
|
||||
pub(super) fn new(device: &wgpu::Device) -> Self {
|
||||
let shader = device.create_shader_module(wgpu::include_wgsl!("convert.wgsl"));
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("basic_brush_convert_bind_group_layout"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("basic_brush_convert_pipeline_layout"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("basic_brush_convert_pipeline"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::TextureFormat::Rgba8Unorm.into())],
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
Self { pipeline, layout }
|
||||
}
|
||||
|
||||
pub(super) fn encode(&self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, source: &wgpu::TextureView, target: &wgpu::TextureView) {
|
||||
let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("basic_brush_convert_bind_group"),
|
||||
layout: &self.layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(source),
|
||||
}],
|
||||
});
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("basic_brush_convert_pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: target,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
depth_slice: None,
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &bind, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
37
node-graph/nodes/brush/src/basic_brush/convert.wgsl
Normal file
37
node-graph/nodes/brush/src/basic_brush/convert.wgsl
Normal file
@@ -0,0 +1,37 @@
|
||||
// =============
|
||||
// VERTEX SHADER
|
||||
// =============
|
||||
|
||||
@vertex
|
||||
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> @builtin(position) vec4<f32> {
|
||||
let pos = array(
|
||||
vec2f(-1.0, -1.0),
|
||||
vec2f(3.0, -1.0),
|
||||
vec2f(-1.0, 3.0),
|
||||
);
|
||||
return vec4f(pos[vertex_index], 0.0, 1.0);
|
||||
}
|
||||
|
||||
// ===============
|
||||
// FRAGMENT SHADER
|
||||
// ===============
|
||||
|
||||
@group(0) @binding(0)
|
||||
var t_composite: texture_2d<f32>;
|
||||
|
||||
fn linear_to_srgb(channel: f32) -> f32 {
|
||||
if (channel <= 0.0031308) {
|
||||
return channel * 12.92;
|
||||
}
|
||||
return 1.055 * pow(channel, 1.0 / 2.4) - 0.055;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(@builtin(position) frag: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let premultiplied = textureLoad(t_composite, vec2<i32>(frag.xy), 0);
|
||||
var straight = vec3<f32>(0.0);
|
||||
if (premultiplied.a > 0.0) {
|
||||
straight = premultiplied.rgb / premultiplied.a;
|
||||
}
|
||||
return vec4<f32>(linear_to_srgb(straight.r), linear_to_srgb(straight.g), linear_to_srgb(straight.b), premultiplied.a);
|
||||
}
|
||||
163
node-graph/nodes/brush/src/basic_brush/kernel.rs
Normal file
163
node-graph/nodes/brush/src/basic_brush/kernel.rs
Normal file
@@ -0,0 +1,163 @@
|
||||
//! Brush kernel baking + caching.
|
||||
//!
|
||||
//! Kernel is a super-Gaussian `exp(-((v^2 + s^2) / 2)^p)`: p = 1 plain Gaussian, higher p
|
||||
//! flattens center + steepens edge. Hardness controls p. Sweep along a segment has no
|
||||
//! closed form, so baked numerically into a texture: row per perpendicular distance,
|
||||
//! columns accumulate the along-axis integral. Segment = two LUT samples,
|
||||
//! `F(v, t) - F(v, t - len)`. Normalized so a long stroke's interior settles at 1.
|
||||
//!
|
||||
//! Calibration defines diameter: find where resolved alpha crosses EDGE_ALPHA, scale so
|
||||
//! that contour lands on `diameter / 2`. Painted width matches the setting, hard or soft.
|
||||
//! p clamped so the edge stays >= MIN_EDGE_TEXELS on screen.
|
||||
//!
|
||||
//! Baked kernels: small LRU keyed by quantized p. Textures from the global pool, held
|
||||
//! weakly; evicted under pressure -> bake again.
|
||||
|
||||
use super::consts::{LUT_CACHE_SIZE, LUT_SIZE, LUT_T_MAX, LUT_V_MAX, RIDGE_GAIN, SIGMA_PER_DIAMETER};
|
||||
use super::stroke::StyledStroke;
|
||||
use glam::UVec2;
|
||||
use raster_types::{Texture, TextureWeakRef};
|
||||
use std::sync::Mutex;
|
||||
use wgpu_executor::WgpuExecutor;
|
||||
|
||||
const INTEGRATE_END: f64 = 12.;
|
||||
const FINE_STEPS: usize = 4096;
|
||||
const MIN_EDGE_TEXELS: f64 = 1.5;
|
||||
|
||||
const EDGE_WIDTH_FACTOR: f64 = 3.09;
|
||||
|
||||
const KEY_STEPS_PER_LN: f64 = 24.;
|
||||
|
||||
const SOFTEST: f64 = 0.7;
|
||||
const HARDEST: f64 = 48.;
|
||||
|
||||
const EDGE_ALPHA: f64 = 0.05;
|
||||
|
||||
pub(super) struct Kernel {
|
||||
pub(super) texture: Texture,
|
||||
pub(super) scale: f32,
|
||||
pub(super) exponent: f32,
|
||||
pub(super) section_scale: f32,
|
||||
}
|
||||
|
||||
struct Baked {
|
||||
scale: f32,
|
||||
exponent: f32,
|
||||
section_scale: f32,
|
||||
texture: TextureWeakRef,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct KernelCache {
|
||||
entries: Mutex<Vec<(i32, Baked)>>,
|
||||
}
|
||||
|
||||
impl KernelCache {
|
||||
pub(super) fn get(&self, executor: &WgpuExecutor, stroke: &StyledStroke, scale: f64) -> Kernel {
|
||||
let sigma_texels = stroke.diameter.max(0.) * SIGMA_PER_DIAMETER * scale;
|
||||
let sharpest = (EDGE_WIDTH_FACTOR * sigma_texels / (2. * MIN_EDGE_TEXELS)).max(1.);
|
||||
let exponent = (SOFTEST * (HARDEST / SOFTEST).powf(stroke.hardness.clamp(0., 1.))).min(sharpest);
|
||||
let key = (exponent.ln() * KEY_STEPS_PER_LN).round() as i32;
|
||||
let mut entries = self.entries.lock().unwrap();
|
||||
if let Some(index) = entries.iter().position(|(cached, _)| *cached == key) {
|
||||
if let Some(texture) = entries[index].1.texture.upgrade() {
|
||||
let entry = entries.remove(index);
|
||||
let kernel = Kernel {
|
||||
texture,
|
||||
scale: entry.1.scale,
|
||||
exponent: entry.1.exponent,
|
||||
section_scale: entry.1.section_scale,
|
||||
};
|
||||
entries.insert(0, entry);
|
||||
return kernel;
|
||||
}
|
||||
entries.remove(index);
|
||||
}
|
||||
let kernel = bake(executor, (key as f64 / KEY_STEPS_PER_LN).exp());
|
||||
let baked = Baked {
|
||||
scale: kernel.scale,
|
||||
exponent: kernel.exponent,
|
||||
section_scale: kernel.section_scale,
|
||||
texture: kernel.texture.downgrade(),
|
||||
};
|
||||
entries.insert(0, (key, baked));
|
||||
entries.truncate(LUT_CACHE_SIZE);
|
||||
kernel
|
||||
}
|
||||
}
|
||||
|
||||
fn kernel(v: f64, s: f64, exponent: f64) -> f64 {
|
||||
(-((v * v + s * s) / 2.).powf(exponent)).exp()
|
||||
}
|
||||
|
||||
fn sweep_row(v: f64, exponent: f64) -> (Vec<f64>, f64) {
|
||||
let ds = 2. * INTEGRATE_END / FINE_STEPS as f64;
|
||||
let mut cumulative = Vec::with_capacity(FINE_STEPS + 1);
|
||||
let mut total = 0.;
|
||||
let mut previous = kernel(v, -INTEGRATE_END, exponent);
|
||||
cumulative.push(0.);
|
||||
for i in 1..=FINE_STEPS {
|
||||
let value = kernel(v, -INTEGRATE_END + i as f64 * ds, exponent);
|
||||
total += (previous + value) / 2. * ds;
|
||||
previous = value;
|
||||
cumulative.push(total);
|
||||
}
|
||||
let samples = (0..LUT_SIZE)
|
||||
.map(|j| {
|
||||
let t = -LUT_T_MAX + j as f64 * 2. * LUT_T_MAX / (LUT_SIZE - 1) as f64;
|
||||
let x = (t + INTEGRATE_END) / ds;
|
||||
let i = (x.floor() as usize).min(FINE_STEPS - 1);
|
||||
cumulative[i] + (cumulative[i + 1] - cumulative[i]) * (x - i as f64)
|
||||
})
|
||||
.collect();
|
||||
(samples, total)
|
||||
}
|
||||
|
||||
fn calibrate(ridge: &[f64], target: f64) -> f64 {
|
||||
let step = LUT_V_MAX / (LUT_SIZE - 1) as f64;
|
||||
let Some(i) = ridge.iter().position(|&r| r < target).filter(|&i| i > 0) else {
|
||||
return LUT_V_MAX;
|
||||
};
|
||||
let (above, below) = (ridge[i - 1], ridge[i]);
|
||||
step * ((i - 1) as f64 + (above - target) / (above - below))
|
||||
}
|
||||
|
||||
fn bake(executor: &WgpuExecutor, exponent: f64) -> Kernel {
|
||||
let mut rows = Vec::with_capacity((LUT_SIZE * LUT_SIZE) as usize);
|
||||
let mut ridge = Vec::with_capacity(LUT_SIZE as usize);
|
||||
let mut norm = 1.;
|
||||
for row in 0..LUT_SIZE {
|
||||
let v = row as f64 * LUT_V_MAX / (LUT_SIZE - 1) as f64;
|
||||
if kernel(v, 0., exponent) < 1e-9 {
|
||||
rows.resize(rows.len() + LUT_SIZE as usize, half::f16::ZERO);
|
||||
ridge.push(0.);
|
||||
continue;
|
||||
}
|
||||
let (samples, total) = sweep_row(v, exponent);
|
||||
if row == 0 {
|
||||
norm = 1. / total;
|
||||
}
|
||||
rows.extend(samples.into_iter().map(|value| half::f16::from_f64(value * norm)));
|
||||
ridge.push(total * norm);
|
||||
}
|
||||
let texture = executor.request_texture_with_format(UVec2::splat(LUT_SIZE), wgpu::TextureFormat::R16Float);
|
||||
executor.context().queue.write_texture(
|
||||
texture.as_image_copy(),
|
||||
bytemuck::cast_slice(&rows),
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(LUT_SIZE * 2),
|
||||
rows_per_image: Some(LUT_SIZE),
|
||||
},
|
||||
texture.size(),
|
||||
);
|
||||
let gain = RIDGE_GAIN as f64;
|
||||
let target = -(1. - EDGE_ALPHA * (1. - (-gain).exp())).ln() / gain;
|
||||
let a = calibrate(&ridge, target);
|
||||
Kernel {
|
||||
texture,
|
||||
scale: (a / 2.) as f32,
|
||||
exponent: exponent as f32,
|
||||
section_scale: ((2. * (1. / target).ln().powf(1. / exponent)).sqrt() / 2.) as f32,
|
||||
}
|
||||
}
|
||||
74
node-graph/nodes/brush/src/basic_brush/mod.rs
Normal file
74
node-graph/nodes/brush/src/basic_brush/mod.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
mod consts;
|
||||
mod convert;
|
||||
mod kernel;
|
||||
mod pipeline;
|
||||
mod region;
|
||||
mod render;
|
||||
mod stroke;
|
||||
|
||||
use brush_types::BrushCache;
|
||||
use core_types::list::{ATTR_COLOR, ATTR_DIAMETER, ATTR_FLOW, ATTR_HARDNESS, Item, List};
|
||||
use core_types::{ATTR_TRANSFORM, Ctx, ExtractFootprint};
|
||||
use graphic_types::Graphic;
|
||||
use pipeline::{BasicBrushPipeline, BasicBrushPipelineArgs};
|
||||
use raster_types::{GPU, Raster};
|
||||
use wgpu_executor::{WgpuExecutor, WgpuPipelineCache};
|
||||
|
||||
#[node_macro::node(category("Raster: Brush"))]
|
||||
pub async fn basic_brush<'a: 'n>(
|
||||
ctx: impl Ctx + ExtractFootprint,
|
||||
strokes: List<Graphic>,
|
||||
#[widget(ParsedWidgetOverride::Hidden)] cache: Item<BrushCache>,
|
||||
#[scope(basic_brush_pipeline::IDENTIFIER)] pipeline: Item<WgpuPipelineCache>,
|
||||
) -> List<Raster<GPU>> {
|
||||
let (cache, pipeline) = (cache.into_element(), pipeline.into_element());
|
||||
let mut stack = vec![strokes.into_iter()];
|
||||
let mut strokes = Vec::new();
|
||||
while let Some(top) = stack.last_mut() {
|
||||
let Some(item) = top.next() else {
|
||||
stack.pop();
|
||||
continue;
|
||||
};
|
||||
let color = item.attribute_cloned_or(ATTR_COLOR, crate::DEFAULT_COLOR);
|
||||
let diameter = item.attribute_cloned_or(ATTR_DIAMETER, crate::DEFAULT_DIAMETER);
|
||||
let hardness = item.attribute_cloned_or(ATTR_HARDNESS, crate::DEFAULT_HARDNESS / 100.);
|
||||
let flow = item.attribute_cloned_or(ATTR_FLOW, crate::DEFAULT_FLOW / 100.);
|
||||
match item.into_element() {
|
||||
Graphic::StrokeList(list) => strokes.extend(
|
||||
list.into_iter()
|
||||
.map(Item::into_element)
|
||||
.filter(|stroke| !stroke.is_empty() && stroke.is_valid())
|
||||
.map(|stroke| stroke::StyledStroke {
|
||||
color,
|
||||
diameter,
|
||||
hardness,
|
||||
flow,
|
||||
stroke,
|
||||
}),
|
||||
),
|
||||
Graphic::GraphicList(nested) => stack.push(nested.into_iter()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let args = BasicBrushPipelineArgs {
|
||||
footprint: *ctx.footprint(),
|
||||
strokes: &strokes,
|
||||
cache: &cache,
|
||||
};
|
||||
let Some((texture, transform)) = pipeline.run::<BasicBrushPipeline>(&args).await else {
|
||||
return List::new();
|
||||
};
|
||||
let raster = Raster::<GPU>::new_gpu(texture);
|
||||
List::new_from_item(Item::new_from_element(raster).with_attribute(ATTR_TRANSFORM, transform))
|
||||
}
|
||||
|
||||
#[node_macro::node(category(""), inject_scope)]
|
||||
async fn basic_brush_pipeline<'a: 'n>(
|
||||
_ctx: impl Ctx,
|
||||
#[scope(ProtoNodeIdentifier::new("graphene_std::platform_application_io::WgpuExecutorNode"))] executor: Item<&'a WgpuExecutor>,
|
||||
#[data] pipeline: WgpuPipelineCache,
|
||||
) -> Item<WgpuPipelineCache> {
|
||||
executor.into_element().pipeline_init::<BasicBrushPipeline>(pipeline);
|
||||
Item::new_from_element(pipeline.clone())
|
||||
}
|
||||
543
node-graph/nodes/brush/src/basic_brush/pipeline.rs
Normal file
543
node-graph/nodes/brush/src/basic_brush/pipeline.rs
Normal file
@@ -0,0 +1,543 @@
|
||||
use super::consts::{LUT_SIZE, LUT_T_MAX, LUT_V_MAX, RIDGE_GAIN, SIGMA_CUTOFF};
|
||||
use super::convert::Convert;
|
||||
use super::kernel::{Kernel, KernelCache};
|
||||
use super::region::{Crop, Region};
|
||||
use super::stroke::{Edge, StyledStroke};
|
||||
use brush_types::BrushCache;
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
use core_types::Color;
|
||||
use core_types::transform::Footprint;
|
||||
use glam::{DAffine2, UVec2};
|
||||
use raster_types::Texture;
|
||||
use wgpu_executor::{AsyncWgpuPipeline, Buffer, WgpuExecutor};
|
||||
|
||||
pub(super) const DENSITY_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R16Float;
|
||||
pub(super) const COMPOSITE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
|
||||
struct ScatterUniforms {
|
||||
frame_size: [f32; 2],
|
||||
kernel_scale: f32,
|
||||
kernel_exponent: f32,
|
||||
kernel_section_scale: f32,
|
||||
_pad: f32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
|
||||
struct ResolveUniforms {
|
||||
color: [f32; 4],
|
||||
density_offset: [f32; 2],
|
||||
_pad: [f32; 2],
|
||||
}
|
||||
|
||||
pub struct BasicBrushPipeline {
|
||||
scatter: Scatter,
|
||||
resolve: Resolve,
|
||||
convert: Convert,
|
||||
kernels: KernelCache,
|
||||
}
|
||||
|
||||
pub(super) struct Field {
|
||||
pub(super) density: Texture,
|
||||
pub(super) stamp: Texture,
|
||||
}
|
||||
|
||||
impl Field {
|
||||
pub(super) fn request(executor: &WgpuExecutor, size: UVec2) -> Self {
|
||||
Self {
|
||||
density: executor.request_texture_with_format(size, DENSITY_FORMAT),
|
||||
stamp: executor.request_texture_with_format(size, DENSITY_FORMAT),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn views(&self) -> FieldViews {
|
||||
FieldViews {
|
||||
density: self.density.create_view(&wgpu::TextureViewDescriptor::default()),
|
||||
stamp: self.stamp.create_view(&wgpu::TextureViewDescriptor::default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct FieldViews {
|
||||
pub(super) density: wgpu::TextureView,
|
||||
pub(super) stamp: wgpu::TextureView,
|
||||
}
|
||||
|
||||
pub struct BasicBrushPipelineArgs<'a> {
|
||||
pub(super) footprint: Footprint,
|
||||
pub(super) strokes: &'a [StyledStroke],
|
||||
pub(super) cache: &'a BrushCache,
|
||||
}
|
||||
|
||||
impl AsyncWgpuPipeline for BasicBrushPipeline {
|
||||
type Args<'a> = BasicBrushPipelineArgs<'a>;
|
||||
type Out = Option<(Texture, DAffine2)>;
|
||||
|
||||
fn create(executor: &WgpuExecutor) -> Self {
|
||||
let device = &executor.context().device;
|
||||
Self {
|
||||
scatter: Scatter::new(device),
|
||||
resolve: Resolve::new(device),
|
||||
convert: Convert::new(device),
|
||||
kernels: KernelCache::default(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run<'a>(&'a self, executor: &'a WgpuExecutor, args: &'a Self::Args<'_>) -> Self::Out {
|
||||
let frame = super::render::Frame::new(args.strokes)?;
|
||||
let region = Region::new(&args.footprint)?;
|
||||
let state = args.cache.take(&args.footprint).unwrap_or_default();
|
||||
let rendered = super::render::render(self, executor, frame, region, state)?;
|
||||
args.cache.store(&args.footprint, rendered.state);
|
||||
Some((rendered.texture, rendered.transform))
|
||||
}
|
||||
}
|
||||
|
||||
struct Scatter {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
sampler: wgpu::Sampler,
|
||||
}
|
||||
|
||||
impl Scatter {
|
||||
fn new(device: &wgpu::Device) -> Self {
|
||||
let shader = device.create_shader_module(wgpu::include_wgsl!("scatter.wgsl"));
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("basic_brush_density_bind_group_layout"),
|
||||
entries: &[
|
||||
uniform_entry(0, wgpu::ShaderStages::VERTEX_FRAGMENT),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("basic_brush_kernel_sampler"),
|
||||
address_mode_u: wgpu::AddressMode::ClampToEdge,
|
||||
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("basic_brush_density_pipeline_layout"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let instance_layout = wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<Edge>() as wgpu::BufferAddress,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &[
|
||||
wgpu::VertexAttribute {
|
||||
offset: 0,
|
||||
shader_location: 0,
|
||||
format: wgpu::VertexFormat::Float32x2,
|
||||
},
|
||||
wgpu::VertexAttribute {
|
||||
offset: 8,
|
||||
shader_location: 1,
|
||||
format: wgpu::VertexFormat::Float32x2,
|
||||
},
|
||||
wgpu::VertexAttribute {
|
||||
offset: 16,
|
||||
shader_location: 2,
|
||||
format: wgpu::VertexFormat::Float32,
|
||||
},
|
||||
wgpu::VertexAttribute {
|
||||
offset: 20,
|
||||
shader_location: 3,
|
||||
format: wgpu::VertexFormat::Float32,
|
||||
},
|
||||
],
|
||||
};
|
||||
let additive = wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::One,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
};
|
||||
let union = wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::One,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Max,
|
||||
};
|
||||
let options = wgpu::PipelineCompilationOptions {
|
||||
constants: &[("CUTOFF_SIGMA", SIGMA_CUTOFF as f64), ("LUT_SIZE", LUT_SIZE as f64), ("LUT_V_MAX", LUT_V_MAX), ("LUT_T_MAX", LUT_T_MAX)],
|
||||
..Default::default()
|
||||
};
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("basic_brush_density_pipeline"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_main"),
|
||||
compilation_options: options.clone(),
|
||||
buffers: &[instance_layout],
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_main"),
|
||||
compilation_options: options,
|
||||
targets: &[
|
||||
Some(wgpu::ColorTargetState {
|
||||
format: DENSITY_FORMAT,
|
||||
blend: Some(wgpu::BlendState { color: additive, alpha: additive }),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
}),
|
||||
Some(wgpu::ColorTargetState {
|
||||
format: DENSITY_FORMAT,
|
||||
blend: Some(wgpu::BlendState { color: union, alpha: union }),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleStrip,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
Self { pipeline, layout, sampler }
|
||||
}
|
||||
|
||||
fn bind(&self, device: &wgpu::Device, globals: &wgpu::Buffer, kernel: &wgpu::TextureView) -> wgpu::BindGroup {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("basic_brush_density_bind_group"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: globals.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(kernel),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
fn encode(&self, encoder: &mut wgpu::CommandEncoder, target: &FieldViews, bind: &wgpu::BindGroup, buffer: &wgpu::Buffer, instances: u32) {
|
||||
let attachment = |view| {
|
||||
Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
depth_slice: None,
|
||||
})
|
||||
};
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("basic_brush_density_pass"),
|
||||
color_attachments: &[attachment(&target.density), attachment(&target.stamp)],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, bind, &[]);
|
||||
pass.set_vertex_buffer(0, buffer.slice(..));
|
||||
pass.draw(0..4, 0..instances);
|
||||
}
|
||||
}
|
||||
|
||||
struct Resolve {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
}
|
||||
|
||||
impl Resolve {
|
||||
fn new(device: &wgpu::Device) -> Self {
|
||||
let shader = device.create_shader_module(wgpu::include_wgsl!("resolve.wgsl"));
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("basic_brush_resolve_bind_group_layout"),
|
||||
entries: &[uniform_entry(0, wgpu::ShaderStages::FRAGMENT), texture_entry(1), texture_entry(2)],
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("basic_brush_resolve_pipeline_layout"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let options = wgpu::PipelineCompilationOptions {
|
||||
constants: &[("RIDGE_GAIN", RIDGE_GAIN as f64), ("RIDGE_NORM", 1. / (1. - (-RIDGE_GAIN as f64).exp()))],
|
||||
..Default::default()
|
||||
};
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("basic_brush_resolve_pipeline"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_main"),
|
||||
compilation_options: options.clone(),
|
||||
buffers: &[],
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_main"),
|
||||
compilation_options: options,
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: COMPOSITE_FORMAT,
|
||||
blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
Self { pipeline, layout }
|
||||
}
|
||||
|
||||
fn bind(&self, device: &wgpu::Device, globals: &wgpu::Buffer, source: &FieldViews) -> wgpu::BindGroup {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("basic_brush_resolve_bind_group"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: globals.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&source.density),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(&source.stamp),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
fn encode(&self, encoder: &mut wgpu::CommandEncoder, target: &wgpu::TextureView, bind: &wgpu::BindGroup, scissor: (UVec2, UVec2)) {
|
||||
let (origin, size) = scissor;
|
||||
if !size.cmpgt(UVec2::ZERO).all() {
|
||||
return;
|
||||
}
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("basic_brush_resolve_pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: target,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
depth_slice: None,
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, bind, &[]);
|
||||
pass.set_scissor_rect(origin.x, origin.y, size.x, size.y);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct Recorder<'a> {
|
||||
pipeline: &'a BasicBrushPipeline,
|
||||
executor: &'a WgpuExecutor,
|
||||
encoder: wgpu::CommandEncoder,
|
||||
region: Region,
|
||||
buffers: Vec<Buffer>,
|
||||
textures: Vec<Texture>,
|
||||
}
|
||||
|
||||
impl<'a> Recorder<'a> {
|
||||
pub(super) fn new(pipeline: &'a BasicBrushPipeline, executor: &'a WgpuExecutor, region: &Region) -> Self {
|
||||
let device = &executor.context().device;
|
||||
let encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("basic_brush_encoder") });
|
||||
Self {
|
||||
pipeline,
|
||||
executor,
|
||||
encoder,
|
||||
region: *region,
|
||||
buffers: Vec::new(),
|
||||
textures: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn kernel(&self, stroke: &StyledStroke) -> Kernel {
|
||||
self.pipeline.kernels.get(self.executor, stroke, self.region.scale)
|
||||
}
|
||||
|
||||
pub(super) fn clear(&mut self, target: &wgpu::TextureView) {
|
||||
self.encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("basic_brush_clear_pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: target,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
depth_slice: None,
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn clear_field(&mut self, target: &FieldViews) {
|
||||
let attachment = |view| {
|
||||
Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
depth_slice: None,
|
||||
})
|
||||
};
|
||||
self.encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("basic_brush_clear_pass"),
|
||||
color_attachments: &[attachment(&target.density), attachment(&target.stamp)],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn scatter(&mut self, target: &FieldViews, edges: &[Edge], kernel: &Kernel) {
|
||||
if edges.is_empty() {
|
||||
return;
|
||||
}
|
||||
let globals = self.executor.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("basic_brush_scatter_uniform"),
|
||||
contents: bytemuck::bytes_of(&ScatterUniforms {
|
||||
frame_size: [self.region.size.x as f32, self.region.size.y as f32],
|
||||
kernel_scale: kernel.scale,
|
||||
kernel_exponent: kernel.exponent,
|
||||
kernel_section_scale: kernel.section_scale,
|
||||
_pad: 0.,
|
||||
}),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let view = kernel.texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
let bind = self.pipeline.scatter.bind(&self.executor.context().device, &globals, &view);
|
||||
let buffer = self.executor.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("basic_brush_segment_buffer"),
|
||||
contents: bytemuck::cast_slice(edges),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
self.pipeline.scatter.encode(&mut self.encoder, target, &bind, &buffer, edges.len() as u32);
|
||||
self.buffers.push(globals);
|
||||
self.buffers.push(buffer);
|
||||
self.textures.push(kernel.texture.clone());
|
||||
}
|
||||
|
||||
pub(super) fn resolve(&mut self, color: Color, crop: &Crop, source: &FieldViews, target: &wgpu::TextureView, scissor: (UVec2, UVec2)) {
|
||||
let globals = resolve_uniform(self.executor, color, crop);
|
||||
let bind = self.pipeline.resolve.bind(&self.executor.context().device, &globals, source);
|
||||
self.pipeline.resolve.encode(&mut self.encoder, target, &bind, scissor);
|
||||
self.buffers.push(globals);
|
||||
}
|
||||
|
||||
pub(super) fn copy(&mut self, from: &Texture, from_origin: UVec2, to: &Texture, to_origin: UVec2) {
|
||||
copy_placed(&mut self.encoder, from, from_origin, to, to_origin);
|
||||
}
|
||||
|
||||
pub(super) fn copy_texture(&mut self, from: &Texture, to: &Texture) {
|
||||
self.encoder.copy_texture_to_texture(from.as_image_copy(), to.as_image_copy(), from.size());
|
||||
}
|
||||
|
||||
pub(super) fn convert(&mut self, source: &wgpu::TextureView, target: &wgpu::TextureView) {
|
||||
self.pipeline.convert.encode(&self.executor.context().device, &mut self.encoder, source, target);
|
||||
}
|
||||
|
||||
pub(super) fn keep(&mut self, texture: Texture) {
|
||||
self.textures.push(texture);
|
||||
}
|
||||
|
||||
pub(super) fn submit(self) {
|
||||
let command = self.encoder.finish();
|
||||
self.executor.context().queue.submit([command]);
|
||||
}
|
||||
}
|
||||
|
||||
fn uniform_entry(binding: u32, visibility: wgpu::ShaderStages) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn texture_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_uniform(executor: &WgpuExecutor, color: Color, crop: &Crop) -> Buffer {
|
||||
let uniforms = ResolveUniforms {
|
||||
color: [color.r(), color.g(), color.b(), color.a()],
|
||||
density_offset: [crop.origin.x as f32, crop.origin.y as f32],
|
||||
_pad: [0.; 2],
|
||||
};
|
||||
executor.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("basic_brush_resolve_uniform"),
|
||||
contents: bytemuck::bytes_of(&uniforms),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
})
|
||||
}
|
||||
|
||||
fn copy_placed(encoder: &mut wgpu::CommandEncoder, from: &wgpu::Texture, from_origin: UVec2, to: &wgpu::Texture, to_origin: UVec2) {
|
||||
let start = from_origin.max(to_origin);
|
||||
let end = (from_origin + UVec2::new(from.width(), from.height())).min(to_origin + UVec2::new(to.width(), to.height()));
|
||||
if !end.cmpgt(start).all() {
|
||||
return;
|
||||
}
|
||||
let info = |texture, origin: UVec2| wgpu::TexelCopyTextureInfo {
|
||||
texture,
|
||||
mip_level: 0,
|
||||
origin: wgpu::Origin3d { x: origin.x, y: origin.y, z: 0 },
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
};
|
||||
let extent = end - start;
|
||||
encoder.copy_texture_to_texture(
|
||||
info(from, start - from_origin),
|
||||
info(to, start - to_origin),
|
||||
wgpu::Extent3d {
|
||||
width: extent.x,
|
||||
height: extent.y,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
67
node-graph/nodes/brush/src/basic_brush/region.rs
Normal file
67
node-graph/nodes/brush/src/basic_brush/region.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
use core_types::math::bbox::AxisAlignedBbox;
|
||||
use core_types::transform::Footprint;
|
||||
use glam::{DAffine2, DVec2, UVec2};
|
||||
|
||||
const MAX_RESOLUTION: u32 = 8192;
|
||||
|
||||
const CROP_STEP: u32 = 256;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub(crate) struct Region {
|
||||
pub(crate) min: DVec2,
|
||||
pub(crate) scale: f64,
|
||||
pub(crate) size: UVec2,
|
||||
}
|
||||
|
||||
impl Region {
|
||||
pub(crate) fn new(footprint: &Footprint) -> Option<Self> {
|
||||
let margin = DVec2::splat(2. / footprint.scale().max_element());
|
||||
let viewport = footprint.viewport_bounds_in_local_space();
|
||||
let bounds = AxisAlignedBbox {
|
||||
start: viewport.start - margin,
|
||||
end: viewport.end + margin,
|
||||
};
|
||||
if !bounds.size().cmpgt(DVec2::ZERO).all() {
|
||||
return None;
|
||||
}
|
||||
// -2 leaves room for the floor/ceil below to add a texel per side at the cap.
|
||||
let scale = footprint.scale().max_element().min((MAX_RESOLUTION as f64 - 2.) / bounds.size().max_element());
|
||||
if !scale.is_finite() || scale <= 0. {
|
||||
return None;
|
||||
}
|
||||
let start = (bounds.start * scale).floor();
|
||||
let end = (bounds.end * scale).ceil();
|
||||
let size = (end - start).as_uvec2().max(UVec2::ONE);
|
||||
Some(Self { min: start / scale, scale, size })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub(crate) struct Crop {
|
||||
pub(crate) origin: UVec2,
|
||||
pub(crate) size: UVec2,
|
||||
}
|
||||
|
||||
impl Crop {
|
||||
pub(crate) fn new(content: AxisAlignedBbox, region: &Region) -> Option<Self> {
|
||||
let start = ((content.start - region.min) * region.scale).floor().max(DVec2::ZERO);
|
||||
let end = ((content.end - region.min) * region.scale).ceil().min(region.size.as_dvec2());
|
||||
if !(end - start).cmpgt(DVec2::ZERO).all() {
|
||||
return None;
|
||||
}
|
||||
let origin = start.as_uvec2() / CROP_STEP * CROP_STEP;
|
||||
let end = ((end.as_uvec2() + UVec2::splat(CROP_STEP - 1)) / CROP_STEP * CROP_STEP).min(region.size);
|
||||
Some(Self { origin, size: end - origin })
|
||||
}
|
||||
|
||||
pub(crate) fn transform(&self, region: &Region) -> DAffine2 {
|
||||
DAffine2::from_translation(region.min + self.origin.as_dvec2() / region.scale) * DAffine2::from_scale(self.size.as_dvec2() / region.scale)
|
||||
}
|
||||
|
||||
pub(crate) fn scissor(&self, region: &Region, bounds: AxisAlignedBbox) -> (UVec2, UVec2) {
|
||||
let clamp = |texels: UVec2| texels.max(self.origin).min(self.origin + self.size) - self.origin;
|
||||
let min = ((bounds.start - region.min) * region.scale).floor().max(DVec2::ZERO).as_uvec2().min(region.size);
|
||||
let max = ((bounds.end - region.min) * region.scale).ceil().max(DVec2::ZERO).as_uvec2().min(region.size);
|
||||
(clamp(min), clamp(max) - clamp(min))
|
||||
}
|
||||
}
|
||||
450
node-graph/nodes/brush/src/basic_brush/render.rs
Normal file
450
node-graph/nodes/brush/src/basic_brush/render.rs
Normal file
@@ -0,0 +1,450 @@
|
||||
use super::pipeline::{BasicBrushPipeline, COMPOSITE_FORMAT, Field, Recorder};
|
||||
use super::region::{Crop, Region};
|
||||
use super::stroke::{self, StyledStroke, Walk};
|
||||
|
||||
use core_types::CacheHash;
|
||||
use core_types::math::bbox::AxisAlignedBbox;
|
||||
use glam::{DAffine2, UVec2};
|
||||
use raster_types::{Texture, TextureWeakRef};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use wgpu_executor::WgpuExecutor;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
struct StrokeKey(u64);
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
struct DensityKey(u64);
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
struct PrefixKey(u64);
|
||||
|
||||
#[derive(PartialEq, Eq)]
|
||||
struct FrameKey {
|
||||
finished: Vec<StrokeKey>,
|
||||
active: StrokeKey,
|
||||
}
|
||||
|
||||
pub(super) struct Frame<'a> {
|
||||
finished: &'a [StyledStroke],
|
||||
active: &'a StyledStroke,
|
||||
}
|
||||
impl<'a> Frame<'a> {
|
||||
pub(super) fn new(strokes: &'a [StyledStroke]) -> Option<Self> {
|
||||
let (active, finished) = strokes.split_last()?;
|
||||
Some(Self { finished, active })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct State {
|
||||
finished: Finished,
|
||||
pending: Option<Pending>,
|
||||
output: Option<CachedOutput>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Finished {
|
||||
strokes: Vec<Record>,
|
||||
image: Option<Placed<TextureWeakRef>>,
|
||||
}
|
||||
|
||||
struct Record {
|
||||
key: StrokeKey,
|
||||
bounds: AxisAlignedBbox,
|
||||
}
|
||||
|
||||
struct Placed<T> {
|
||||
texture: T,
|
||||
origin: UVec2,
|
||||
}
|
||||
|
||||
struct CachedOutput {
|
||||
key: FrameKey,
|
||||
texture: TextureWeakRef,
|
||||
}
|
||||
|
||||
struct Pending {
|
||||
key: PendingKey,
|
||||
walk: Walk,
|
||||
density: TextureWeakRef,
|
||||
stamp: TextureWeakRef,
|
||||
}
|
||||
|
||||
struct LivePending {
|
||||
key: PendingKey,
|
||||
walk: Walk,
|
||||
field: Field,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct PendingKey {
|
||||
seed: u64,
|
||||
density: DensityKey,
|
||||
prefix: PrefixKey,
|
||||
}
|
||||
|
||||
impl PendingKey {
|
||||
fn new(stroke: &StyledStroke, consumed: usize) -> Self {
|
||||
Self {
|
||||
seed: stroke.stroke.seed,
|
||||
density: density_key(stroke),
|
||||
prefix: prefix_key(stroke, consumed),
|
||||
}
|
||||
}
|
||||
|
||||
fn matches(&self, stroke: &StyledStroke, consumed: usize) -> bool {
|
||||
self.seed == stroke.stroke.seed && self.density == density_key(stroke) && self.prefix == prefix_key(stroke, consumed)
|
||||
}
|
||||
}
|
||||
|
||||
impl Pending {
|
||||
fn upgrade(self, region: &Region) -> Option<LivePending> {
|
||||
let density = self.density.upgrade()?;
|
||||
let stamp = self.stamp.upgrade()?;
|
||||
if density.width() != region.size.x || density.height() != region.size.y {
|
||||
return None;
|
||||
}
|
||||
Some(LivePending {
|
||||
key: self.key,
|
||||
walk: self.walk,
|
||||
field: Field { density, stamp },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl LivePending {
|
||||
fn matches(&self, stroke: &StyledStroke) -> bool {
|
||||
self.walk.consumed > 0 && self.walk.consumed <= stroke.stroke.len() && self.key.matches(stroke, self.walk.consumed)
|
||||
}
|
||||
|
||||
fn park(self) -> Pending {
|
||||
Pending {
|
||||
key: self.key,
|
||||
walk: self.walk,
|
||||
density: self.field.density.downgrade(),
|
||||
stamp: self.field.stamp.downgrade(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct Rendered {
|
||||
pub(super) texture: Texture,
|
||||
pub(super) transform: DAffine2,
|
||||
pub(super) state: State,
|
||||
}
|
||||
|
||||
pub(super) fn render(pipeline: &BasicBrushPipeline, executor: &WgpuExecutor, frame: Frame<'_>, region: Region, mut state: State) -> Option<Rendered> {
|
||||
let keys: Vec<_> = frame.finished.iter().map(stroke_key).collect();
|
||||
let active_key = stroke_key(frame.active);
|
||||
let frame_key = frame_key(&keys, active_key);
|
||||
let prefix = state.finished.strokes.len() <= keys.len() && state.finished.strokes.iter().zip(&keys).all(|(cached, current)| cached.key == *current);
|
||||
let known = if prefix { state.finished.strokes.len() } else { 0 };
|
||||
let mut bounds: Vec<_> = if prefix {
|
||||
state.finished.strokes.iter().map(|record| record.bounds.clone()).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
bounds.extend(frame.finished[known..].iter().map(|stroke| stroke::bounds(stroke, region.scale)));
|
||||
let active_bounds = stroke::bounds(frame.active, region.scale);
|
||||
let mut content = None;
|
||||
for bounds in &bounds {
|
||||
stroke::union(&mut content, bounds.clone());
|
||||
}
|
||||
stroke::union(&mut content, active_bounds.clone());
|
||||
let crop = Crop::new(content?, ®ion)?;
|
||||
|
||||
if let Some(texture) = state.output.as_ref().filter(|output| output.key == frame_key).and_then(|output| output.texture.upgrade()) {
|
||||
return Some(Rendered {
|
||||
texture,
|
||||
transform: crop.transform(®ion),
|
||||
state,
|
||||
});
|
||||
}
|
||||
|
||||
let base = state
|
||||
.finished
|
||||
.image
|
||||
.take()
|
||||
.and_then(|placed| {
|
||||
Some(Placed {
|
||||
texture: placed.texture.upgrade()?,
|
||||
origin: placed.origin,
|
||||
})
|
||||
})
|
||||
.filter(|placed| prefix && (placed.origin + UVec2::new(placed.texture.width(), placed.texture.height())).cmple(region.size).all());
|
||||
let covered = if base.is_some() { state.finished.strokes.len() } else { 0 };
|
||||
let missing = &frame.finished[covered..];
|
||||
let pending = state.pending.take().and_then(|pending| pending.upgrade(®ion));
|
||||
let (active_pending, mut finished_pending) = match pending {
|
||||
Some(pending) if pending.matches(frame.active) => (Some(pending), None),
|
||||
pending => (None, pending),
|
||||
};
|
||||
|
||||
let updated = (!missing.is_empty()).then(|| executor.request_texture_with_format(crop.size, COMPOSITE_FORMAT));
|
||||
let composite = executor.request_texture_with_format(crop.size, COMPOSITE_FORMAT);
|
||||
let scratch = Field::request(executor, region.size);
|
||||
let output = executor.request_texture(crop.size);
|
||||
let mut recorder = Recorder::new(pipeline, executor, ®ion);
|
||||
|
||||
if let Some(updated) = &updated {
|
||||
let target = updated.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
recorder.clear(&target);
|
||||
if let Some(base) = &base {
|
||||
recorder.copy(&base.texture, base.origin, updated, crop.origin);
|
||||
}
|
||||
let mut strokes = StrokeRenderer {
|
||||
recorder: &mut recorder,
|
||||
executor,
|
||||
region: ®ion,
|
||||
crop: &crop,
|
||||
scratch: &scratch,
|
||||
};
|
||||
for (index, stroke) in missing.iter().enumerate() {
|
||||
let scissor = crop.scissor(®ion, bounds[covered + index].clone());
|
||||
if !scissor.1.cmpgt(UVec2::ZERO).all() {
|
||||
continue;
|
||||
}
|
||||
let previous = if finished_pending.as_ref().is_some_and(|pending| pending.matches(stroke)) {
|
||||
finished_pending.take()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
strokes.render(stroke, previous, Tail::Commit, Target { view: &target, scissor });
|
||||
}
|
||||
}
|
||||
|
||||
let composite_view = composite.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
match (&updated, &base) {
|
||||
(Some(updated), _) => recorder.copy_texture(updated, &composite),
|
||||
(None, Some(base)) => {
|
||||
recorder.clear(&composite_view);
|
||||
recorder.copy(&base.texture, base.origin, &composite, crop.origin);
|
||||
}
|
||||
(None, None) => recorder.clear(&composite_view),
|
||||
}
|
||||
let active_scissor = crop.scissor(®ion, active_bounds);
|
||||
let pending = StrokeRenderer {
|
||||
recorder: &mut recorder,
|
||||
executor,
|
||||
region: ®ion,
|
||||
crop: &crop,
|
||||
scratch: &scratch,
|
||||
}
|
||||
.render(
|
||||
frame.active,
|
||||
active_pending,
|
||||
Tail::Preview,
|
||||
Target {
|
||||
view: &composite_view,
|
||||
scissor: active_scissor,
|
||||
},
|
||||
)?;
|
||||
|
||||
let output_view = output.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
recorder.convert(&composite_view, &output_view);
|
||||
recorder.submit();
|
||||
|
||||
let image = updated
|
||||
.map(|texture| Placed {
|
||||
texture: texture.downgrade(),
|
||||
origin: crop.origin,
|
||||
})
|
||||
.or_else(|| {
|
||||
base.map(|placed| Placed {
|
||||
texture: placed.texture.downgrade(),
|
||||
origin: placed.origin,
|
||||
})
|
||||
});
|
||||
let state = State {
|
||||
finished: Finished {
|
||||
strokes: keys.into_iter().zip(bounds).map(|(key, bounds)| Record { key, bounds }).collect(),
|
||||
image,
|
||||
},
|
||||
pending: Some(pending.park()),
|
||||
output: Some(CachedOutput {
|
||||
key: frame_key,
|
||||
texture: output.downgrade(),
|
||||
}),
|
||||
};
|
||||
Some(Rendered {
|
||||
texture: output,
|
||||
transform: crop.transform(®ion),
|
||||
state,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Tail {
|
||||
Commit,
|
||||
Preview,
|
||||
}
|
||||
|
||||
enum Density<'a> {
|
||||
Temporary(&'a Field),
|
||||
Owned(Field),
|
||||
}
|
||||
|
||||
impl Density<'_> {
|
||||
fn field(&self) -> &Field {
|
||||
match self {
|
||||
Self::Temporary(field) => field,
|
||||
Self::Owned(field) => field,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Target<'a> {
|
||||
view: &'a wgpu::TextureView,
|
||||
scissor: (UVec2, UVec2),
|
||||
}
|
||||
|
||||
struct StrokeRenderer<'a, 'gpu> {
|
||||
recorder: &'a mut Recorder<'gpu>,
|
||||
executor: &'gpu WgpuExecutor,
|
||||
region: &'a Region,
|
||||
crop: &'a Crop,
|
||||
scratch: &'a Field,
|
||||
}
|
||||
|
||||
impl StrokeRenderer<'_, '_> {
|
||||
fn render(&mut self, stroke: &StyledStroke, previous: Option<LivePending>, tail: Tail, target: Target<'_>) -> Option<LivePending> {
|
||||
let (mut walk, density) = match previous {
|
||||
Some(pending) => (pending.walk, Density::Owned(pending.field)),
|
||||
None => match tail {
|
||||
Tail::Commit => (Walk::default(), Density::Temporary(self.scratch)),
|
||||
Tail::Preview => (Walk::default(), Density::Owned(Field::request(self.executor, self.region.size))),
|
||||
},
|
||||
};
|
||||
let views = density.field().views();
|
||||
if walk.consumed == 0 {
|
||||
self.recorder.clear_field(&views);
|
||||
}
|
||||
let kernel = self.recorder.kernel(stroke);
|
||||
let mut update = walk.update(stroke, self.region);
|
||||
match tail {
|
||||
Tail::Commit => {
|
||||
update.committed.append(&mut update.tail);
|
||||
self.recorder.scatter(&views, &update.committed, &kernel);
|
||||
self.recorder.resolve(stroke.color, self.crop, &views, target.view, target.scissor);
|
||||
if let Density::Owned(field) = density {
|
||||
self.recorder.keep(field.density);
|
||||
self.recorder.keep(field.stamp);
|
||||
}
|
||||
None
|
||||
}
|
||||
Tail::Preview => {
|
||||
self.recorder.scatter(&views, &update.committed, &kernel);
|
||||
if update.tail.is_empty() {
|
||||
self.recorder.resolve(stroke.color, self.crop, &views, target.view, target.scissor);
|
||||
} else {
|
||||
let field = density.field();
|
||||
self.recorder.copy_texture(&field.density, &self.scratch.density);
|
||||
self.recorder.copy_texture(&field.stamp, &self.scratch.stamp);
|
||||
let scratch_views = self.scratch.views();
|
||||
self.recorder.scatter(&scratch_views, &update.tail, &kernel);
|
||||
self.recorder.resolve(stroke.color, self.crop, &scratch_views, target.view, target.scissor);
|
||||
}
|
||||
let Density::Owned(field) = density else { unreachable!() };
|
||||
Some(LivePending {
|
||||
key: PendingKey::new(stroke, walk.consumed),
|
||||
walk,
|
||||
field,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stroke_key(stroke: &StyledStroke) -> StrokeKey {
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
stroke.stroke.cache_hash(&mut hasher);
|
||||
stroke.color.cache_hash(&mut hasher);
|
||||
stroke.diameter.cache_hash(&mut hasher);
|
||||
stroke.hardness.cache_hash(&mut hasher);
|
||||
stroke.flow.cache_hash(&mut hasher);
|
||||
StrokeKey(hasher.finish())
|
||||
}
|
||||
|
||||
fn density_key(stroke: &StyledStroke) -> DensityKey {
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
(stroke.diameter.max(0.) as f32).to_bits().hash(&mut hasher);
|
||||
(stroke.hardness.clamp(0., 1.) as f32).to_bits().hash(&mut hasher);
|
||||
(stroke.flow.clamp(0., 1.) as f32).to_bits().hash(&mut hasher);
|
||||
DensityKey(hasher.finish())
|
||||
}
|
||||
|
||||
fn prefix_key(stroke: &StyledStroke, consumed: usize) -> PrefixKey {
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
for sample in stroke.stroke.samples().take(consumed) {
|
||||
sample.position.x.to_bits().hash(&mut hasher);
|
||||
sample.position.y.to_bits().hash(&mut hasher);
|
||||
sample.pressure.clamp(0., 1.).to_bits().hash(&mut hasher);
|
||||
}
|
||||
PrefixKey(hasher.finish())
|
||||
}
|
||||
|
||||
fn frame_key(finished: &[StrokeKey], active: StrokeKey) -> FrameKey {
|
||||
FrameKey { finished: finished.to_vec(), active }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use brush_types::{Channel, Stroke};
|
||||
use core_types::Color;
|
||||
use glam::DVec2;
|
||||
|
||||
fn stroke() -> StyledStroke {
|
||||
StyledStroke {
|
||||
color: Color::BLACK,
|
||||
diameter: 20.,
|
||||
hardness: 0.8,
|
||||
flow: 1.,
|
||||
stroke: Stroke {
|
||||
position: vec![DVec2::new(1., 2.), DVec2::new(3., 4.), DVec2::new(5., 6.)],
|
||||
pressure: Channel::Samples(vec![0.2, 0.4, 0.6]),
|
||||
seed: 42,
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_key_accepts_an_appended_stroke() {
|
||||
let original = stroke();
|
||||
let key = PendingKey::new(&original, original.stroke.len());
|
||||
let mut appended = stroke();
|
||||
appended.stroke.position.push(DVec2::new(7., 8.));
|
||||
let Channel::Samples(pressure) = &mut appended.stroke.pressure else { unreachable!() };
|
||||
pressure.push(0.8);
|
||||
assert!(key.matches(&appended, original.stroke.len()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_key_rejects_changed_render_data() {
|
||||
let original = stroke();
|
||||
let key = PendingKey::new(&original, original.stroke.len());
|
||||
|
||||
let mut position = stroke();
|
||||
position.stroke.position[0].x += 1.;
|
||||
assert!(!key.matches(&position, original.stroke.len()));
|
||||
|
||||
let mut pressure = stroke();
|
||||
let Channel::Samples(samples) = &mut pressure.stroke.pressure else { unreachable!() };
|
||||
samples[1] += 0.1;
|
||||
assert!(!key.matches(&pressure, original.stroke.len()));
|
||||
|
||||
let mut flow = stroke();
|
||||
flow.flow *= 0.5;
|
||||
assert!(!key.matches(&flow, original.stroke.len()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_key_ignores_color() {
|
||||
let original = stroke();
|
||||
let key = PendingKey::new(&original, original.stroke.len());
|
||||
let mut recolored = stroke();
|
||||
recolored.color = Color::WHITE;
|
||||
assert!(key.matches(&recolored, original.stroke.len()));
|
||||
}
|
||||
}
|
||||
43
node-graph/nodes/brush/src/basic_brush/resolve.wgsl
Normal file
43
node-graph/nodes/brush/src/basic_brush/resolve.wgsl
Normal file
@@ -0,0 +1,43 @@
|
||||
// =============
|
||||
// VERTEX SHADER
|
||||
// =============
|
||||
|
||||
@vertex
|
||||
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> @builtin(position) vec4<f32> {
|
||||
let pos = array(
|
||||
vec2f(-1.0, -1.0),
|
||||
vec2f(3.0, -1.0),
|
||||
vec2f(-1.0, 3.0),
|
||||
);
|
||||
return vec4f(pos[vertex_index], 0.0, 1.0);
|
||||
}
|
||||
|
||||
// ===============
|
||||
// FRAGMENT SHADER
|
||||
// ===============
|
||||
|
||||
struct Uniforms {
|
||||
color: vec4<f32>,
|
||||
density_offset: vec2<f32>,
|
||||
_pad: vec2<f32>,
|
||||
};
|
||||
|
||||
override RIDGE_GAIN: f32;
|
||||
override RIDGE_NORM: f32;
|
||||
|
||||
@group(0) @binding(0)
|
||||
var<uniform> uniforms: Uniforms;
|
||||
|
||||
@group(0) @binding(1)
|
||||
var t_density: texture_2d<f32>;
|
||||
|
||||
@group(0) @binding(2)
|
||||
var t_stamp: texture_2d<f32>;
|
||||
|
||||
@fragment
|
||||
fn fs_main(@builtin(position) frag: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let texel = vec2<i32>(frag.xy + uniforms.density_offset);
|
||||
let field = max(textureLoad(t_density, texel, 0).r, textureLoad(t_stamp, texel, 0).r);
|
||||
let alpha = clamp((1.0 - exp(-field * RIDGE_GAIN)) * RIDGE_NORM, 0.0, 1.0) * uniforms.color.a;
|
||||
return vec4<f32>(uniforms.color.rgb * alpha, alpha);
|
||||
}
|
||||
117
node-graph/nodes/brush/src/basic_brush/scatter.wgsl
Normal file
117
node-graph/nodes/brush/src/basic_brush/scatter.wgsl
Normal file
@@ -0,0 +1,117 @@
|
||||
override CUTOFF_SIGMA: f32;
|
||||
|
||||
override LUT_SIZE: f32;
|
||||
override LUT_V_MAX: f32;
|
||||
override LUT_T_MAX: f32;
|
||||
|
||||
// =============
|
||||
// VERTEX SHADER
|
||||
// =============
|
||||
|
||||
struct Uniforms {
|
||||
frame_size: vec2<f32>,
|
||||
kernel_scale: f32,
|
||||
kernel_exponent: f32,
|
||||
kernel_section_scale: f32,
|
||||
};
|
||||
|
||||
@group(0) @binding(0)
|
||||
var<uniform> uniforms: Uniforms;
|
||||
|
||||
@group(0) @binding(1)
|
||||
var t_kernel: texture_2d<f32>;
|
||||
|
||||
@group(0) @binding(2)
|
||||
var s_kernel: sampler;
|
||||
|
||||
struct VertexOutput {
|
||||
@builtin(position) clip_position: vec4<f32>,
|
||||
@location(0) @interpolate(flat) a: vec2<f32>,
|
||||
@location(1) @interpolate(flat) b: vec2<f32>,
|
||||
@location(2) @interpolate(flat) sigma: f32,
|
||||
@location(3) @interpolate(flat) weight: f32,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_main(
|
||||
@builtin(vertex_index) vertex_index: u32,
|
||||
@location(0) a: vec2<f32>,
|
||||
@location(1) b: vec2<f32>,
|
||||
@location(2) sigma: f32,
|
||||
@location(3) weight: f32,
|
||||
) -> VertexOutput {
|
||||
let cutoff = CUTOFF_SIGMA * sigma;
|
||||
|
||||
let d = b - a;
|
||||
let len = length(d);
|
||||
var e = vec2f(1.0, 0.0);
|
||||
if (len > 1e-6) {
|
||||
e = d / len;
|
||||
}
|
||||
let n = vec2f(-e.y, e.x);
|
||||
let base = select(a - e * cutoff, b + e * cutoff, (vertex_index & 1u) == 1u);
|
||||
let normal_sign = select(-1.0, 1.0, vertex_index >= 2u);
|
||||
let corner = base + n * (cutoff * normal_sign);
|
||||
|
||||
let ndc = vec2f(corner.x / uniforms.frame_size.x * 2.0 - 1.0, 1.0 - corner.y / uniforms.frame_size.y * 2.0);
|
||||
|
||||
var out: VertexOutput;
|
||||
out.clip_position = vec4f(ndc, 0.0, 1.0);
|
||||
out.a = a;
|
||||
out.b = b;
|
||||
out.sigma = sigma;
|
||||
out.weight = weight;
|
||||
return out;
|
||||
}
|
||||
|
||||
// ===============
|
||||
// FRAGMENT SHADER
|
||||
// ===============
|
||||
|
||||
fn sweep(v: f32, t: f32) -> f32 {
|
||||
let texel = (LUT_SIZE - 1.0) / LUT_SIZE;
|
||||
let uv = vec2f(
|
||||
((t + LUT_T_MAX) / (2.0 * LUT_T_MAX)) * texel + 0.5 / LUT_SIZE,
|
||||
(v / LUT_V_MAX) * texel + 0.5 / LUT_SIZE,
|
||||
);
|
||||
return textureSampleLevel(t_kernel, s_kernel, uv, 0.0).r;
|
||||
}
|
||||
|
||||
fn section(r2: f32) -> f32 {
|
||||
// Max avoids pow undefined log at zero.
|
||||
return exp(-pow(max(r2 * 0.5, 1e-20), uniforms.kernel_exponent));
|
||||
}
|
||||
|
||||
struct FragmentOutput {
|
||||
@location(0) density: f32,
|
||||
@location(1) stamp: f32,
|
||||
};
|
||||
|
||||
@fragment
|
||||
fn fs_main(in: VertexOutput) -> FragmentOutput {
|
||||
let p = in.clip_position.xy;
|
||||
let inv_section = uniforms.kernel_section_scale / in.sigma;
|
||||
let d = in.b - in.a;
|
||||
let len = length(d);
|
||||
if (len < 1e-6) {
|
||||
let dab = in.weight * section(dot(p - in.a, p - in.a) * inv_section * inv_section);
|
||||
return FragmentOutput(dab, dab);
|
||||
}
|
||||
let e = d / len;
|
||||
let rel = p - in.a;
|
||||
let along = dot(rel, e);
|
||||
let perp2 = max(dot(rel, rel) - along * along, 0.0);
|
||||
|
||||
let cutoff = CUTOFF_SIGMA * in.sigma;
|
||||
if (perp2 > cutoff * cutoff || along < -cutoff || along > len + cutoff) {
|
||||
return FragmentOutput(0.0, 0.0);
|
||||
}
|
||||
let inv_sp = uniforms.kernel_scale / in.sigma;
|
||||
let v = sqrt(perp2) * inv_sp;
|
||||
let ridge = sweep(v, along * inv_sp) - sweep(v, (along - len) * inv_sp);
|
||||
|
||||
let overhang = max(max(-along, along - len), 0.0);
|
||||
let stamp = in.weight * section((perp2 + overhang * overhang) * inv_section * inv_section);
|
||||
|
||||
return FragmentOutput(in.weight * max(ridge, 0.0), stamp);
|
||||
}
|
||||
222
node-graph/nodes/brush/src/basic_brush/stroke.rs
Normal file
222
node-graph/nodes/brush/src/basic_brush/stroke.rs
Normal file
@@ -0,0 +1,222 @@
|
||||
use super::consts::{RIDGE_GAIN, SIGMA_CUTOFF, SIGMA_PER_DIAMETER};
|
||||
use brush_types::{Sample, Stroke};
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
use core_types::Color;
|
||||
use core_types::math::bbox::AxisAlignedBbox;
|
||||
use glam::DVec2;
|
||||
|
||||
const MIN_SIGMA: f32 = f32::EPSILON;
|
||||
const MAX_EDGE_SHIFT: f32 = 0.25;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Pod, Zeroable)]
|
||||
pub(super) struct Edge {
|
||||
a: [f32; 2],
|
||||
b: [f32; 2],
|
||||
sigma: f32,
|
||||
weight: f32,
|
||||
}
|
||||
|
||||
pub(super) struct StyledStroke {
|
||||
pub(super) color: Color,
|
||||
pub(super) diameter: f64,
|
||||
pub(super) hardness: f64,
|
||||
pub(super) flow: f64,
|
||||
pub(super) stroke: Stroke,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Dab {
|
||||
position: DVec2,
|
||||
sigma: f32,
|
||||
weight: f32,
|
||||
}
|
||||
|
||||
fn dab(sample: &Sample, stroke: &StyledStroke) -> Dab {
|
||||
let pressure = sample.pressure.clamp(0., 1.);
|
||||
let flow = stroke.flow.clamp(0., 1.) as f32;
|
||||
Dab {
|
||||
position: sample.position,
|
||||
sigma: (stroke.diameter.max(0.) * SIGMA_PER_DIAMETER) as f32 * pressure,
|
||||
weight: -(1. - flow * (1. - (-RIDGE_GAIN).exp())).ln() / RIDGE_GAIN,
|
||||
}
|
||||
}
|
||||
|
||||
fn dab_pad(dab: Dab, scale: f64) -> AxisAlignedBbox {
|
||||
let sigma = (dab.sigma as f64).max(MIN_SIGMA as f64 / scale);
|
||||
let pad = DVec2::splat(SIGMA_CUTOFF as f64 * sigma + 1f64.max(1. / scale));
|
||||
AxisAlignedBbox {
|
||||
start: dab.position - pad,
|
||||
end: dab.position + pad,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn union(bounds: &mut Option<AxisAlignedBbox>, other: AxisAlignedBbox) {
|
||||
*bounds = Some(match bounds.take() {
|
||||
Some(existing) => existing.union(&other),
|
||||
None => other,
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn bounds(stroke: &StyledStroke, scale: f64) -> AxisAlignedBbox {
|
||||
let mut bounds = None;
|
||||
for sample in stroke.stroke.samples() {
|
||||
union(&mut bounds, dab_pad(dab(&sample, stroke), scale));
|
||||
}
|
||||
bounds.unwrap_or(AxisAlignedBbox::ZERO)
|
||||
}
|
||||
|
||||
pub(super) struct Update {
|
||||
pub(super) committed: Vec<Edge>,
|
||||
pub(super) tail: Vec<Edge>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct Walk {
|
||||
sigma_min: f32,
|
||||
kept_last: Option<Dab>,
|
||||
kept: usize,
|
||||
pub(super) consumed: usize,
|
||||
}
|
||||
|
||||
impl Default for Walk {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sigma_min: f32::MAX,
|
||||
kept_last: None,
|
||||
kept: 0,
|
||||
consumed: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Walk {
|
||||
fn advance(&mut self, stroke: &StyledStroke, scale: f64) -> Vec<Dab> {
|
||||
let mut kept = Vec::new();
|
||||
for index in self.consumed..stroke.stroke.len() {
|
||||
let sample = stroke.stroke.sample(index);
|
||||
let dab = dab(&sample, stroke);
|
||||
self.sigma_min = self.sigma_min.min(dab.sigma);
|
||||
let min_step = (self.sigma_min as f64 * 0.5).max(0.5 / scale);
|
||||
if self.kept_last.is_none_or(|last| last.position.distance(dab.position) >= min_step) {
|
||||
kept.push(dab);
|
||||
self.kept_last = Some(dab);
|
||||
self.kept += 1;
|
||||
}
|
||||
}
|
||||
self.consumed = stroke.stroke.len();
|
||||
kept
|
||||
}
|
||||
|
||||
fn tail(&self, stroke: &StyledStroke) -> Option<(Dab, Dab)> {
|
||||
let kept_last = self.kept_last?;
|
||||
let dab = dab(&stroke.stroke.sample(stroke.stroke.len() - 1), stroke);
|
||||
if dab.position == kept_last.position {
|
||||
return (self.kept == 1).then_some((kept_last, kept_last));
|
||||
}
|
||||
Some((kept_last, dab))
|
||||
}
|
||||
|
||||
pub(super) fn update(&mut self, stroke: &StyledStroke, region: &super::region::Region) -> Update {
|
||||
let previous = self.kept_last;
|
||||
let kept = self.advance(stroke, region.scale);
|
||||
let tail = self.tail(stroke);
|
||||
Update {
|
||||
committed: edges(region, previous, &kept, None),
|
||||
tail: edges(region, None, &[], tail),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn texel(region: &super::region::Region, p: DVec2) -> [f32; 2] {
|
||||
[((p.x - region.min.x) * region.scale) as f32, ((p.y - region.min.y) * region.scale) as f32]
|
||||
}
|
||||
|
||||
fn edge(region: &super::region::Region, a: Dab, b: Dab) -> Edge {
|
||||
Edge {
|
||||
a: texel(region, a.position),
|
||||
b: texel(region, b.position),
|
||||
sigma: ((a.sigma + b.sigma) / 2. * region.scale as f32).max(MIN_SIGMA),
|
||||
weight: (a.weight + b.weight) / 2.,
|
||||
}
|
||||
}
|
||||
|
||||
fn mix(a: Dab, b: Dab, t: f32) -> Dab {
|
||||
Dab {
|
||||
position: a.position.lerp(b.position, t as f64),
|
||||
sigma: a.sigma + (b.sigma - a.sigma) * t,
|
||||
weight: a.weight + (b.weight - a.weight) * t,
|
||||
}
|
||||
}
|
||||
|
||||
fn segment_edges(edges: &mut Vec<Edge>, region: &super::region::Region, a: Dab, b: Dab) {
|
||||
let scale = region.scale as f32;
|
||||
let gradient = (a.sigma.min(b.sigma) * scale).max(1.);
|
||||
let shift = (b.sigma - a.sigma).abs() * scale * SIGMA_CUTOFF;
|
||||
let pieces = (shift / (MAX_EDGE_SHIFT * gradient)).ceil().clamp(1., 64.) as usize;
|
||||
let mut previous = a;
|
||||
for piece in 1..=pieces {
|
||||
let next = if piece == pieces { b } else { mix(a, b, piece as f32 / pieces as f32) };
|
||||
edges.push(edge(region, previous, next));
|
||||
previous = next;
|
||||
}
|
||||
}
|
||||
|
||||
fn edges(region: &super::region::Region, prev: Option<Dab>, kept: &[Dab], tail: Option<(Dab, Dab)>) -> Vec<Edge> {
|
||||
let mut edges = Vec::with_capacity(kept.len() + 1);
|
||||
let mut last = prev;
|
||||
for &dab in kept {
|
||||
if let Some(previous) = last {
|
||||
segment_edges(&mut edges, region, previous, dab);
|
||||
}
|
||||
last = Some(dab);
|
||||
}
|
||||
if let Some((a, b)) = tail {
|
||||
segment_edges(&mut edges, region, a, b);
|
||||
}
|
||||
edges
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use glam::{DVec2, UVec2};
|
||||
|
||||
fn stroke(points: &[[f64; 2]]) -> StyledStroke {
|
||||
StyledStroke {
|
||||
color: Color::BLACK,
|
||||
diameter: 20.,
|
||||
hardness: 0.8,
|
||||
flow: 1.,
|
||||
stroke: Stroke {
|
||||
position: points.iter().copied().map(DVec2::from).collect(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn region() -> super::super::region::Region {
|
||||
super::super::region::Region {
|
||||
min: DVec2::ZERO,
|
||||
scale: 2.,
|
||||
size: UVec2::splat(512),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunked_walk_matches_whole_stroke() {
|
||||
let partial = stroke(&[[10., 10.], [15., 12.], [20., 15.]]);
|
||||
let complete = stroke(&[[10., 10.], [15., 12.], [20., 15.], [31., 19.], [45., 24.]]);
|
||||
let region = region();
|
||||
|
||||
let mut chunked = Walk::default();
|
||||
let first = chunked.update(&partial, ®ion);
|
||||
let second = chunked.update(&complete, ®ion);
|
||||
let mut committed = first.committed;
|
||||
committed.extend(second.committed);
|
||||
|
||||
let whole = Walk::default().update(&complete, ®ion);
|
||||
assert_eq!(committed, whole.committed);
|
||||
assert_eq!(second.tail, whole.tail);
|
||||
}
|
||||
}
|
||||
@@ -1,492 +0,0 @@
|
||||
use crate::brush_cache::BrushCache;
|
||||
use crate::brush_stroke::{BrushStroke, BrushStyle};
|
||||
use core_types::attribute::{Attr, BlendMode as BlendModeAttr, ClippingMask, EditorLayerPath, Opacity, OpacityFill, Transform as TransformAttr};
|
||||
use core_types::blending::BlendMode;
|
||||
use core_types::bounds::{BoundingBox, RenderBoundingBox};
|
||||
use core_types::color::{Alpha, Color, Pixel, Sample};
|
||||
use core_types::extent::{LevelIn, ListIn};
|
||||
use core_types::gpoll::{Extent, GPoll, GraphError, Interrupt};
|
||||
use core_types::list::{Item, List};
|
||||
use core_types::math::bbox::{AxisAlignedBbox, Bbox};
|
||||
use core_types::transform::Transform;
|
||||
use core_types::uuid::NodeId;
|
||||
use core_types::{ATTR_BLEND_MODE, ATTR_CLIPPING_MASK, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM};
|
||||
use core_types::{Ctx, ExtractIndex, InjectIndex};
|
||||
use glam::{DAffine2, DVec2};
|
||||
use raster_nodes::blending_nodes::blend_colors;
|
||||
use raster_nodes::std_nodes::{empty_image_core, extend_image_to_bounds_core};
|
||||
use raster_types::BitmapMut;
|
||||
use raster_types::Image;
|
||||
use raster_types::{CPU, Raster};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, dyn_any::DynAny)]
|
||||
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)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Controls the brush shape with diameter and hardness, plus color and opacity (via flow).
|
||||
/// The feather exponent is calculated from hardness to determine edge softness.
|
||||
/// Used internally to create the brush texture before stamping it repeatedly along a stroke path.
|
||||
#[node_macro::node(category(""), skip_impl)]
|
||||
fn brush_stamp_generator(_: impl Ctx, #[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 }
|
||||
}
|
||||
|
||||
/// Used to efficiently paint brush strokes. Applies the same texture repeatedly at different positions with proper blending and boundary handling.
|
||||
#[node_macro::node(category(""), skip_impl)]
|
||||
fn blit<BlendFn: Fn(Color, Color) -> Color>(_: impl Ctx, mut target: List<Raster<CPU>>, texture: Raster<CPU>, positions: Vec<DVec2>, blend_mode: BlendFn) -> List<Raster<CPU>> {
|
||||
if positions.is_empty() {
|
||||
return target;
|
||||
}
|
||||
|
||||
let (elements, transforms) = target.element_and_attribute_slices_mut::<DAffine2>(ATTR_TRANSFORM);
|
||||
for (element, transform_attribute) in elements.iter_mut().zip(transforms.iter()) {
|
||||
let target_width = element.width;
|
||||
let target_height = 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) * transform_attribute.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) < 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 element.data_mut().data[target_index(x + clamp_start.x, y + clamp_start.y)];
|
||||
*dst_pixel = blend_mode(src_pixel, *dst_pixel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
target
|
||||
}
|
||||
|
||||
pub 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 = {
|
||||
let mut item = Item::new_from_element(empty_image_core(transform, Color::TRANSPARENT));
|
||||
item.set_attribute(ATTR_TRANSFORM, transform);
|
||||
item
|
||||
};
|
||||
let image = blend_stamp_closure(stamp, blank_texture, |a, b| blend_colors(a, b, BlendMode::Normal, 1.));
|
||||
|
||||
image.into_element()
|
||||
}
|
||||
|
||||
pub fn blend_with_mode(background: Item<Raster<CPU>>, foreground: Item<Raster<CPU>>, blend_mode: BlendMode, opacity: f64) -> Item<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)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lane 0 of the materialized background as the legacy item the brush core works
|
||||
/// on; an empty level starts from a blank item, as the pre-flip node did.
|
||||
fn legacy_background(background: core_types::node::List<'_, Raster<CPU>>) -> Item<Raster<CPU>> {
|
||||
if background.is_empty() {
|
||||
return Item::default();
|
||||
}
|
||||
let lane = background.lane(0);
|
||||
let mut item = Item::new_from_element(background.element_ref(0).clone());
|
||||
item.set_attribute(ATTR_TRANSFORM, lane.attr::<TransformAttr>());
|
||||
item.set_attribute(ATTR_BLEND_MODE, lane.attr::<BlendModeAttr>());
|
||||
item.set_attribute(ATTR_OPACITY, lane.attr::<Opacity>());
|
||||
item.set_attribute(ATTR_OPACITY_FILL, lane.attr::<OpacityFill>());
|
||||
item.set_attribute(ATTR_CLIPPING_MASK, lane.attr::<ClippingMask>());
|
||||
item
|
||||
}
|
||||
|
||||
/// The brushed image replaces the whole background level with one lane.
|
||||
fn brush_extent(_background: ListIn<'_, Raster<CPU>>, _trace: ListIn<'_, BrushStroke>, _level: LevelIn) -> GPoll<Extent> {
|
||||
GPoll::Final(Extent::Exactly(1))
|
||||
}
|
||||
|
||||
/// 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"), extent(brush_extent))]
|
||||
fn brush<'e>(
|
||||
ctx: impl Ctx + ExtractArena<'e> + ExtractIndex + InjectIndex + Copy,
|
||||
/// Optional raster content that may be drawn onto.
|
||||
background: IList<Raster<CPU>>,
|
||||
/// The list of brush stroke paths drawn by the Brush tool, with each including both its coordinates and styles.
|
||||
trace: IList<BrushStroke>,
|
||||
/// Internal cache data used to accelerate rendering of the brush content.
|
||||
#[data]
|
||||
cache: BrushCache,
|
||||
) -> Result<
|
||||
IList<(
|
||||
Raster<CPU>,
|
||||
Attr<'e, TransformAttr>,
|
||||
Attr<'e, BlendModeAttr>,
|
||||
Attr<'e, Opacity>,
|
||||
Attr<'e, OpacityFill>,
|
||||
Attr<'e, ClippingMask>,
|
||||
Attr<'e, EditorLayerPath>,
|
||||
)>,
|
||||
Interrupt,
|
||||
> {
|
||||
if ctx.innermost_index() > 0 {
|
||||
return Err(GraphError::past_end().into());
|
||||
}
|
||||
// The layer path only rides through, so it is read off the source lane
|
||||
// rather than round-tripped as a legacy list attribute.
|
||||
let layer_path: Vec<NodeId> = match background.is_empty() {
|
||||
true => Vec::new(),
|
||||
false => background.lane(0).attr::<EditorLayerPath>().to_vec(),
|
||||
};
|
||||
let strokes: Vec<BrushStroke> = (0..trace.len()).map(|row| trace.element_ref(row).clone()).collect();
|
||||
let actual_image = brush_core(legacy_background(background), strokes, cache);
|
||||
|
||||
let transform: DAffine2 = actual_image.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||||
let blend_mode: BlendMode = actual_image.attribute_cloned_or_default(ATTR_BLEND_MODE);
|
||||
let opacity: f64 = actual_image.attribute_cloned_or(ATTR_OPACITY, 1.);
|
||||
let fill: f64 = actual_image.attribute_cloned_or(ATTR_OPACITY_FILL, 1.);
|
||||
let clip: bool = actual_image.attribute_cloned_or_default(ATTR_CLIPPING_MASK);
|
||||
let layer_path = ctx
|
||||
.arena()
|
||||
.alloc(layer_path)
|
||||
.ok_or_else(|| {
|
||||
Interrupt::from(GraphError {
|
||||
kind: core_types::gpoll::ErrorKind::ArenaExhausted,
|
||||
trace: Vec::new(),
|
||||
})
|
||||
})?
|
||||
.0;
|
||||
|
||||
Ok((
|
||||
actual_image.into_element(),
|
||||
Attr(transform),
|
||||
Attr(blend_mode),
|
||||
Attr(opacity),
|
||||
Attr(fill),
|
||||
Attr(clip),
|
||||
Attr(layer_path.as_slice()),
|
||||
))
|
||||
}
|
||||
|
||||
/// The pre-flip brush body, on legacy items: one background item plus every
|
||||
/// stroke in order, returning the painted image.
|
||||
fn brush_core(list_item: Item<Raster<CPU>>, strokes: Vec<BrushStroke>, cache: &BrushCache) -> Item<Raster<CPU>> {
|
||||
let bounds = List::new_from_item(list_item.clone()).bounding_box(DAffine2::IDENTITY, false);
|
||||
let [start, end] = if let RenderBoundingBox::Rectangle(rect) = bounds { rect } else { [DVec2::ZERO, DVec2::ZERO] };
|
||||
let background_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 background_bbox.size().length() < 0.1 {
|
||||
stroke_bbox
|
||||
} else {
|
||||
stroke_bbox.union(&background_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(list_item, &draw_strokes);
|
||||
|
||||
// TODO: Find a way to handle more than one item
|
||||
let mut actual_image = {
|
||||
let background = brush_plan.background;
|
||||
let transform: DAffine2 = background.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||||
let (element, attributes) = background.into_parts();
|
||||
let (element, transform) = extend_image_to_bounds_core(element, transform, background_bounds);
|
||||
let mut item = Item::from_parts(element, attributes);
|
||||
item.set_attribute(ATTR_TRANSFORM, transform);
|
||||
item
|
||||
};
|
||||
|
||||
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);
|
||||
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 blit_target = if idx == 0 {
|
||||
let target = core::mem::take(&mut brush_plan.first_stroke_texture);
|
||||
let transform: DAffine2 = target.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||||
let (element, attributes) = target.into_parts();
|
||||
let (element, transform) = extend_image_to_bounds_core(element, transform, stroke_to_layer);
|
||||
let mut item = Item::from_parts(element, attributes);
|
||||
item.set_attribute(ATTR_TRANSFORM, transform);
|
||||
List::new_from_item(item)
|
||||
} else {
|
||||
let mut item = Item::new_from_element(empty_image_core(stroke_to_layer, Color::TRANSPARENT));
|
||||
item.set_attribute(ATTR_TRANSFORM, stroke_to_layer);
|
||||
List::new_from_item(item)
|
||||
};
|
||||
|
||||
let list = blit(&(), blit_target, brush_texture, positions, |a, b| blend_colors(a, b, BlendMode::Normal, 1.));
|
||||
assert_eq!(list.len(), 1);
|
||||
list.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 = Item::new_from_element(Raster::new_cpu(opaque_image)).with_attribute(ATTR_TRANSFORM, background_bounds);
|
||||
|
||||
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);
|
||||
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,
|
||||
};
|
||||
|
||||
erase_restore_mask = blit(&(), List::new_from_item(erase_restore_mask), brush_texture, positions, move |a, b| {
|
||||
blend_colors(a, b, mask_blend_mode, 1.)
|
||||
})
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap_or_default();
|
||||
}
|
||||
|
||||
actual_image = blend_image_closure(erase_restore_mask, actual_image, |a, b| blend_colors(a, b, BlendMode::MultiplyAlpha, 1.));
|
||||
}
|
||||
|
||||
actual_image
|
||||
}
|
||||
|
||||
pub fn blend_image_closure(foreground: Item<Raster<CPU>>, mut background: Item<Raster<CPU>>, map_fn: impl Fn(Color, Color) -> Color) -> Item<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 foreground_transform: DAffine2 = foreground.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||||
let background_transform: DAffine2 = background.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||||
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_mut().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: Item<Raster<CPU>>, map_fn: impl Fn(Color, Color) -> Color) -> Item<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_transform: DAffine2 = background.attribute_cloned_or_default(ATTR_TRANSFORM);
|
||||
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_mut().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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_brush_output_size() {
|
||||
let image = brush_core(
|
||||
Item::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(),
|
||||
);
|
||||
assert_eq!(image.element().width, 20);
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
use crate::brush_stroke::BrushStroke;
|
||||
use crate::brush_stroke::BrushStyle;
|
||||
use core_types::ATTR_TRANSFORM;
|
||||
use core_types::graphene_hash::CacheHashWrapper;
|
||||
use core_types::list::Item;
|
||||
use raster_types::CPU;
|
||||
use raster_types::Raster;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct BrushCacheImpl {
|
||||
// The full previous input that was cached.
|
||||
prev_input: Vec<BrushStroke>,
|
||||
|
||||
// The strokes that have been fully processed and blended into the background.
|
||||
background: Item<Raster<CPU>>,
|
||||
blended_image: Item<Raster<CPU>>,
|
||||
last_stroke_texture: Item<Raster<CPU>>,
|
||||
|
||||
// A cache for brush textures.
|
||||
brush_texture_cache: HashMap<CacheHashWrapper<BrushStyle>, Raster<CPU>>,
|
||||
}
|
||||
|
||||
impl BrushCacheImpl {
|
||||
fn compute_brush_plan(&mut self, mut background: Item<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.
|
||||
// Transform is set to ZERO (not the default IDENTITY) as a sentinel to mark this item as uninitialized.
|
||||
let mut first_stroke_texture = Item::new_from_element(Raster::<CPU>::default()).with_attribute(ATTR_TRANSFORM, glam::DAffine2::ZERO);
|
||||
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: Item<Raster<CPU>>, last_stroke_texture: Item<Raster<CPU>>) {
|
||||
self.prev_input = input;
|
||||
self.blended_image = blended_image;
|
||||
self.last_stroke_texture = last_stroke_texture;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct BrushPlan {
|
||||
pub strokes: Vec<BrushStroke>,
|
||||
pub background: Item<Raster<CPU>>,
|
||||
pub first_stroke_texture: Item<Raster<CPU>>,
|
||||
pub first_stroke_point_skip: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct BrushCache(Arc<Mutex<BrushCacheImpl>>);
|
||||
|
||||
impl BrushCache {
|
||||
pub fn compute_brush_plan(&self, background: Item<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: Item<Raster<CPU>>, last_stroke_texture: Item<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(&CacheHashWrapper(style.clone())).cloned()
|
||||
}
|
||||
|
||||
pub fn store_brush(&self, style: BrushStyle, brush: Raster<CPU>) {
|
||||
let mut inner = self.0.lock().unwrap();
|
||||
inner.brush_texture_cache.insert(CacheHashWrapper(style), brush);
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
use core_types::CacheHash;
|
||||
use core_types::blending::BlendMode;
|
||||
use core_types::color::Color;
|
||||
use core_types::math::bbox::AxisAlignedBbox;
|
||||
use dyn_any::DynAny;
|
||||
use glam::DVec2;
|
||||
/// The style of a brush.
|
||||
#[derive(Clone, Debug, CacheHash, DynAny)]
|
||||
#[cfg_attr(feature = "serde", derive(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 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, core_types::CacheHash, DynAny)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct BrushInputSample {
|
||||
pub position: DVec2,
|
||||
}
|
||||
|
||||
/// The parameters for a single stroke brush.
|
||||
#[derive(Clone, Debug, PartialEq, core_types::CacheHash, Default, DynAny)]
|
||||
#[cfg_attr(feature = "serde", derive(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
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,32 @@
|
||||
pub mod brush;
|
||||
mod brush_cache;
|
||||
pub mod brush_stroke;
|
||||
use core_types::list::{ATTR_COLOR, ATTR_DIAMETER, ATTR_FLOW, ATTR_HARDNESS, Item, List};
|
||||
use core_types::registry::types::Percentage;
|
||||
use core_types::{Color, Ctx};
|
||||
use graphic_types::Graphic;
|
||||
|
||||
pub mod migrations {
|
||||
use crate::brush_stroke::BrushStroke;
|
||||
pub mod basic_brush;
|
||||
|
||||
// TODO: Eventually remove this migration document upgrade code
|
||||
pub fn migrate_to_brush_strokes<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Vec<BrushStroke>, D::Error> {
|
||||
use serde::Deserialize;
|
||||
pub use brush_types::*;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct LegacyTable {
|
||||
#[serde(alias = "instances", alias = "instance")]
|
||||
element: Vec<BrushStroke>,
|
||||
}
|
||||
pub(crate) const DEFAULT_DIAMETER: f64 = 40.;
|
||||
pub(crate) const DEFAULT_HARDNESS: f64 = 0.;
|
||||
pub(crate) const DEFAULT_FLOW: f64 = 100.;
|
||||
pub(crate) const DEFAULT_COLOR: Color = Color::BLACK;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum BrushStrokesFormat {
|
||||
Strokes(Vec<BrushStroke>),
|
||||
List(LegacyTable),
|
||||
}
|
||||
|
||||
Ok(match BrushStrokesFormat::deserialize(deserializer)? {
|
||||
BrushStrokesFormat::Strokes(strokes) => strokes,
|
||||
BrushStrokesFormat::List(list) => list.element,
|
||||
})
|
||||
}
|
||||
#[node_macro::node(category("Raster: Brush"))]
|
||||
fn brush_strokes(
|
||||
_: impl Ctx,
|
||||
strokes: List<Stroke>,
|
||||
color: List<Color>,
|
||||
#[default(DEFAULT_DIAMETER)] diameter: Item<f64>,
|
||||
#[default(DEFAULT_HARDNESS)] hardness: Item<Percentage>,
|
||||
#[default(DEFAULT_FLOW)] flow: Item<Percentage>,
|
||||
) -> List<Graphic> {
|
||||
let (diameter, hardness, flow) = (diameter.into_element(), hardness.into_element(), flow.into_element());
|
||||
List::new_from_item(
|
||||
Item::new_from_element(Graphic::from(strokes))
|
||||
.with_attribute(ATTR_COLOR, color.element(0).copied().unwrap_or_default())
|
||||
.with_attribute(ATTR_DIAMETER, diameter.max(0.))
|
||||
.with_attribute(ATTR_HARDNESS, (hardness / 100.).clamp(0., 1.))
|
||||
.with_attribute(ATTR_FLOW, (flow / 100.).clamp(0., 1.)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use core_types::list::List;
|
||||
use core_types::transform::Footprint;
|
||||
use core_types::{CacheHash, Color, Context, Ctx, DeriveCtx, ExtractAnimationTime, ExtractPointerPosition, ExtractRealTime};
|
||||
use glam::{DAffine2, DVec2};
|
||||
use graphic_types::vector_types::GradientStops;
|
||||
use graphic_types::vector_types::Gradient;
|
||||
use graphic_types::{Artboard, Graphic, Vector};
|
||||
use raster_types::{CPU, GPU, Raster};
|
||||
|
||||
@@ -36,17 +36,20 @@ fn real_time(
|
||||
/// The time and date component to be produced as a number.
|
||||
component: RealTimeMode,
|
||||
) -> f64 {
|
||||
let component = component.into_element();
|
||||
let real_time = ctx.try_real_time().unwrap_or_default();
|
||||
|
||||
// TODO: Implement proper conversion using and existing time implementation
|
||||
match component {
|
||||
let result = 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.,
|
||||
}
|
||||
};
|
||||
|
||||
Item::new_from_element(result)
|
||||
}
|
||||
|
||||
/// Produces the time, in seconds on the timeline, since the beginning of animation playback.
|
||||
@@ -58,7 +61,7 @@ fn animation_time(
|
||||
#[unit("/sec")]
|
||||
rate: f64,
|
||||
) -> f64 {
|
||||
ctx.try_animation_time().unwrap_or_default() * rate
|
||||
Item::new_from_element(ctx.try_animation_time().unwrap_or_default() * *rate.element())
|
||||
}
|
||||
|
||||
#[node_macro::node(category("Debug"))]
|
||||
@@ -74,16 +77,23 @@ fn quantize_real_time<T>(
|
||||
Context -> DAffine2,
|
||||
Context -> Footprint,
|
||||
Context -> DVec2,
|
||||
Context -> Vector,
|
||||
Context -> Graphic,
|
||||
Context -> Raster<CPU>,
|
||||
Context -> Raster<GPU>,
|
||||
Context -> Color,
|
||||
Context -> Gradient,
|
||||
Context -> Artboard,
|
||||
Context -> List<String>,
|
||||
Context -> List<f64>,
|
||||
Context -> List<DVec2>,
|
||||
Context -> List<Vector>,
|
||||
Context -> List<Graphic>,
|
||||
Context -> List<Raster<CPU>>,
|
||||
Context -> List<Raster<GPU>>,
|
||||
Context -> List<Color>,
|
||||
Context -> List<Gradient>,
|
||||
Context -> List<Artboard>,
|
||||
Context -> List<GradientStops>,
|
||||
Context -> List<String>,
|
||||
Context -> List<f64>,
|
||||
Context -> (),
|
||||
)]
|
||||
value: impl Node<Context<'_>, Output = T>,
|
||||
#[default(1)]
|
||||
@@ -92,6 +102,7 @@ fn quantize_real_time<T>(
|
||||
) -> GPoll<T> {
|
||||
let time = ctx.try_real_time().unwrap_or_default();
|
||||
let time = time / 1000.;
|
||||
let quantum = quantum.into_element();
|
||||
let mut quantized_time = (time * quantum.recip()).round() / quantum.recip();
|
||||
if !quantized_time.is_finite() {
|
||||
quantized_time = time;
|
||||
@@ -114,16 +125,23 @@ fn quantize_animation_time<T>(
|
||||
Context -> DAffine2,
|
||||
Context -> Footprint,
|
||||
Context -> DVec2,
|
||||
Context -> Vector,
|
||||
Context -> Graphic,
|
||||
Context -> Raster<CPU>,
|
||||
Context -> Raster<GPU>,
|
||||
Context -> Color,
|
||||
Context -> Gradient,
|
||||
Context -> Artboard,
|
||||
Context -> List<String>,
|
||||
Context -> List<f64>,
|
||||
Context -> List<DVec2>,
|
||||
Context -> List<Vector>,
|
||||
Context -> List<Graphic>,
|
||||
Context -> List<Raster<CPU>>,
|
||||
Context -> List<Raster<GPU>>,
|
||||
Context -> List<Color>,
|
||||
Context -> List<Gradient>,
|
||||
Context -> List<Artboard>,
|
||||
Context -> List<GradientStops>,
|
||||
Context -> List<String>,
|
||||
Context -> List<f64>,
|
||||
Context -> (),
|
||||
)]
|
||||
value: impl Node<Context<'_>, Output = T>,
|
||||
#[default(1)]
|
||||
@@ -131,6 +149,7 @@ fn quantize_animation_time<T>(
|
||||
quantum: f64,
|
||||
) -> GPoll<T> {
|
||||
let time = ctx.try_animation_time().unwrap_or_default();
|
||||
let quantum = quantum.into_element();
|
||||
let mut quantized_time = (time * quantum.recip()).round() / quantum.recip();
|
||||
if !quantized_time.is_finite() {
|
||||
quantized_time = time;
|
||||
@@ -142,7 +161,7 @@ fn quantize_animation_time<T>(
|
||||
/// Produces the current position of the user's pointer within the document canvas.
|
||||
#[node_macro::node(category("Animation"))]
|
||||
fn pointer_position(ctx: impl Ctx + ExtractPointerPosition) -> DVec2 {
|
||||
ctx.try_pointer_position().unwrap_or_default()
|
||||
Item::new_from_element(ctx.try_pointer_position().unwrap_or_default())
|
||||
}
|
||||
|
||||
// TODO: These nodes require more sophisticated algorithms for giving the correct result
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use core_types::gpoll::{Extent, GPoll, GraphError, Interrupt};
|
||||
use core_types::list::List;
|
||||
use core_types::list::{Item, List};
|
||||
use core_types::{Color, ExtractVarArgs};
|
||||
use core_types::{Ctx, ExtractIndex, ExtractIndices, ExtractPosition};
|
||||
use glam::DVec2;
|
||||
use graphic_types::vector_types::GradientStops;
|
||||
use graphic_types::vector_types::Gradient;
|
||||
use graphic_types::{Graphic, Vector};
|
||||
use raster_types::{CPU, Raster};
|
||||
|
||||
@@ -40,7 +40,7 @@ fn read_color(ctx: impl Ctx + ExtractVarArgs) -> List<Color> {
|
||||
}
|
||||
|
||||
#[node_macro::node(category("Context"), path(graphene_core::vector))]
|
||||
fn read_gradient(ctx: impl Ctx + ExtractVarArgs) -> List<GradientStops> {
|
||||
fn read_gradient(ctx: impl Ctx + ExtractVarArgs) -> List<Gradient> {
|
||||
let Ok(var_arg) = ctx.vararg(0) else { return Default::default() };
|
||||
let var_arg = var_arg as &dyn std::any::Any;
|
||||
|
||||
@@ -111,12 +111,49 @@ fn read_color_row_extent<C: Ctx + ExtractVarArgs>(_: &ReadColorRowNode, ctx: &C,
|
||||
|
||||
/// Rank-model vararg source: the mapped row's items as lanes, elements only.
|
||||
#[node_macro::node(category("Test"), extent_raw(read_gradient_row_extent))]
|
||||
pub fn read_gradient_row(ctx: impl Ctx + ExtractVarArgs + ExtractIndex) -> Result<IList<GradientStops>, Interrupt> {
|
||||
pub fn read_gradient_row(ctx: impl Ctx + ExtractVarArgs + ExtractIndex) -> Result<IList<Gradient>, Interrupt> {
|
||||
vararg_element(ctx)
|
||||
}
|
||||
|
||||
fn read_gradient_row_extent<C: Ctx + ExtractVarArgs>(_: &ReadGradientRowNode, ctx: &C, level: u8) -> GPoll<Extent> {
|
||||
vararg_lanes::<GradientStops>(ctx, level)
|
||||
vararg_lanes::<Gradient>(ctx, level)
|
||||
}
|
||||
|
||||
/// Widens a numeric vararg row into `f64`, keeping each item's attributes.
|
||||
fn widen_vararg<T: Clone + 'static>(var_arg: &dyn std::any::Any, widen: impl Fn(T) -> f64) -> Option<List<f64>> {
|
||||
let list = var_arg.downcast_ref::<List<T>>()?.clone();
|
||||
Some(
|
||||
list.into_iter()
|
||||
.map(|item| {
|
||||
let (element, attributes) = item.into_parts();
|
||||
Item::from_parts(widen(element), attributes)
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Reads the current number from within a **Map** node's loop.
|
||||
#[node_macro::node(category("Context"))]
|
||||
fn read_number(ctx: impl Ctx + ExtractVarArgs) -> List<f64> {
|
||||
let Ok(var_arg) = ctx.vararg(0) else { return Default::default() };
|
||||
let var_arg = var_arg as &dyn std::any::Any;
|
||||
|
||||
if let Some(list) = var_arg.downcast_ref::<List<f64>>() {
|
||||
return list.clone();
|
||||
}
|
||||
|
||||
// Numeric rows carry several possible element types, so probe each and widen to f64
|
||||
if let Some(list) = widen_vararg(var_arg, |value: f32| value as f64) {
|
||||
return list;
|
||||
}
|
||||
if let Some(list) = widen_vararg(var_arg, |value: u32| value as f64) {
|
||||
return list;
|
||||
}
|
||||
if let Some(list) = widen_vararg(var_arg, |value: u64| value as f64) {
|
||||
return list;
|
||||
}
|
||||
|
||||
Default::default()
|
||||
}
|
||||
|
||||
#[node_macro::node(category("Context"), path(core_types::vector))]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use core_types::Ctx;
|
||||
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"))]
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
use core_types::list::Item;
|
||||
use core_types::{CacheHash, 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 {
|
||||
/// The inverse of this node is **Combine Vec2**, which composes a vec2 from its X and Y components.
|
||||
#[node_macro::node(name("Extract XY"), category("Math: Vec2"))]
|
||||
fn extract_xy<T: Into<DVec2>>(_: impl Ctx, #[implementations(DVec2, IVec2, UVec2)] vector: Item<T>, axis: Item<XY>) -> Item<f64> {
|
||||
let vector = vector.into_element();
|
||||
let axis = axis.into_element();
|
||||
|
||||
let result = match axis {
|
||||
XY::X => vector.into().x,
|
||||
XY::Y => vector.into().y,
|
||||
}
|
||||
};
|
||||
|
||||
Item::new_from_element(result)
|
||||
}
|
||||
|
||||
/// The X or Y component of a vec2.
|
||||
|
||||
@@ -9,6 +9,7 @@ fn passthrough<T: Send>(_: impl Ctx, content: T) -> T {
|
||||
content
|
||||
}
|
||||
|
||||
/// Shifts a whole value onto a connector's type through the std `Into` trait, serving the whole-`List` erasure onto `ListDyn` under the input adapter identifier.
|
||||
#[node_macro::node(category(""), skip_impl)]
|
||||
fn into<T: Send + Into<O>, O: Send>(_: impl Ctx, value: T, #[data] _out_ty: PhantomData<O>) -> O {
|
||||
value.into()
|
||||
|
||||
@@ -8,6 +8,7 @@ authors.workspace = true
|
||||
[dependencies]
|
||||
# Local dependencies
|
||||
core-types = { workspace = true }
|
||||
brush-types = { workspace = true }
|
||||
graphic-types = { workspace = true }
|
||||
vector-types = { workspace = true }
|
||||
raster-types = { workspace = true }
|
||||
@@ -17,3 +18,4 @@ dyn-any = { workspace = true }
|
||||
glam = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
node-macro = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
use brush_types::Stroke;
|
||||
use core_types::attribute::{Attr, EditorLayerPath, Transform as TransformAttr};
|
||||
use core_types::bounds::{BoundingBox, RenderBoundingBox};
|
||||
use core_types::extent::{ExtentIn, LevelIn, ListIn, ValueIn};
|
||||
use core_types::gpoll::{Extent, GPoll, GraphError, Interrupt, Level};
|
||||
use core_types::list::List;
|
||||
use core_types::registry::types::{Angle, SignedInteger};
|
||||
use core_types::list::{Item, List, ListDyn};
|
||||
use core_types::registry::types::{Angle, SeedValue, SignedInteger};
|
||||
use core_types::uuid::NodeId;
|
||||
use core_types::{ATTR_EDITOR_LAYER_PATH, ATTR_TRANSFORM, CacheHash, Color, Ctx, DeriveCtx, ExtractIndex, InjectIndex, ModifyIndex};
|
||||
use glam::{DAffine2, DVec2};
|
||||
use graphic_types::graphic::{Graphic, IntoGraphicList};
|
||||
use graphic_types::graphic::{Graphic, IntoGraphicList, is_lone_anonymous_leaf};
|
||||
use graphic_types::{ATTR_EDITOR_MERGED_LAYERS, Artboard, Vector};
|
||||
use raster_types::{CPU, GPU, Raster};
|
||||
|
||||
use vector_types::{GradientStop, GradientStops, ReferencePoint};
|
||||
use rand::SeedableRng;
|
||||
use rand::seq::SliceRandom;
|
||||
use std::cmp::Ordering;
|
||||
use vector_types::{Gradient, ReferencePoint};
|
||||
|
||||
/// Resolves a signed index over `total` lanes: negatives count from the end,
|
||||
/// out of range resolves to nothing.
|
||||
@@ -98,7 +101,7 @@ fn omit_element_extent(list: ExtentIn<'_>, index: ValueIn<'_, f64>, level: Level
|
||||
pub fn extract_element<T: Clone + Default + Send + Sync + CacheHash + 'static>(
|
||||
_: impl Ctx,
|
||||
/// The `List` of data to extract from.
|
||||
#[implementations(String, f64, NodeId, Color, GradientStops, Vector, Raster<CPU>, Graphic, Artboard)]
|
||||
#[implementations(String, f64, NodeId, Color, Gradient, Vector, Raster<CPU>, Graphic, Artboard)]
|
||||
list: IList<T>,
|
||||
/// The index of the item to retrieve, starting from 0 for the first item. Negative indices count backwards from the end of the list, starting from -1 for the last item.
|
||||
index: SignedInteger,
|
||||
@@ -112,7 +115,7 @@ pub fn extract_element<T: Clone + Default + Send + Sync + CacheHash + 'static>(
|
||||
#[node_macro::node(category("General"))]
|
||||
fn map<Row: Clone + Send + Sync + CacheHash + 'static, T>(
|
||||
ctx: impl Ctx + DeriveCtx + ExtractIndex + InjectIndex + Copy,
|
||||
#[implementations(Graphic, Vector, Raster<CPU>, Color, GradientStops, String)] content: IList<Row>,
|
||||
#[implementations(Graphic, Vector, Raster<CPU>, Color, Gradient, String)] content: IList<Row>,
|
||||
mapped: impl Node<Context<'_>, Output = IList<T>>,
|
||||
) -> Result<IList<T>, Interrupt> {
|
||||
let mut remaining = ctx.index();
|
||||
@@ -425,9 +428,9 @@ fn extend_extent(base: ExtentIn<'_>, new: ExtentIn<'_>, level: LevelIn) -> GPoll
|
||||
#[node_macro::node(category(""))]
|
||||
pub fn legacy_layer_extend<T: Send + Clone>(
|
||||
_: impl Ctx,
|
||||
#[implementations(List<Artboard>, List<Graphic>, List<Vector>, List<String>, List<Raster<CPU>>, List<Raster<GPU>>, List<Color>, List<GradientStops>)] base: List<T>,
|
||||
#[implementations(List<Artboard>, List<Graphic>, List<Vector>, List<String>, List<Raster<CPU>>, List<Raster<GPU>>, List<Color>, List<Gradient>)] base: List<T>,
|
||||
#[expose]
|
||||
#[implementations(List<Artboard>, List<Graphic>, List<Vector>, List<String>, List<Raster<CPU>>, List<Raster<GPU>>, List<Color>, List<GradientStops>)]
|
||||
#[implementations(List<Artboard>, List<Graphic>, List<Vector>, List<String>, List<Raster<CPU>>, List<Raster<GPU>>, List<Color>, List<Gradient>)]
|
||||
new: List<T>,
|
||||
nested_node_path: List<NodeId>,
|
||||
) -> List<T> {
|
||||
@@ -447,21 +450,21 @@ pub fn legacy_layer_extend<T: Send + Clone>(
|
||||
base
|
||||
}
|
||||
|
||||
/// Nests the input graphical content in a wrapper graphic. This essentially "groups" the input.
|
||||
/// The wrapped run keeps the level's element type, so the legacy boundary can
|
||||
/// lower a wrapped vector level to the bare typed graphic the pre-flip wrap made.
|
||||
/// Nests the input graphical content in a wrapper graphic, collecting it all into a single group.
|
||||
/// The collected run keeps the level's element type, so the legacy boundary can
|
||||
/// lower a collected vector level to the bare typed graphic the pre-flip wrap made.
|
||||
/// The inverse of this node is 'Flatten Graphic'.
|
||||
#[node_macro::node(category("General"), extent(wrap_graphic_extent))]
|
||||
pub fn wrap_graphic<'e, T: Clone + Send + Sync + core_types::CacheHash + 'static>(
|
||||
#[node_macro::node(category("General"), extent(into_group_extent))]
|
||||
pub fn into_group<'e, T: Clone + Send + Sync + core_types::CacheHash + 'static>(
|
||||
_: impl Ctx,
|
||||
#[implementations(Graphic, Vector, Raster<CPU>, Raster<GPU>, Color, GradientStops, String)] content: IList<T>,
|
||||
#[implementations(Graphic, Vector, Raster<CPU>, Raster<GPU>, Color, Gradient, String)] content: IList<T>,
|
||||
) -> Result<IList<Graphic<'e>>, Interrupt> {
|
||||
let item = content.as_group_item();
|
||||
Ok(Graphic::Group(core_types::record::Group { row: None, content: item }))
|
||||
}
|
||||
|
||||
/// The collected group is the level's single lane.
|
||||
fn wrap_graphic_extent<T>(_content: ListIn<'_, T>, _level: LevelIn) -> GPoll<Extent> {
|
||||
fn into_group_extent<T>(_content: ListIn<'_, T>, _level: LevelIn) -> GPoll<Extent> {
|
||||
GPoll::Final(Extent::Exactly(1))
|
||||
}
|
||||
|
||||
@@ -469,7 +472,9 @@ fn wrap_graphic_extent<T>(_content: ListIn<'_, T>, _level: LevelIn) -> GPoll<Ext
|
||||
/// unchanged; a typed level nests as one graphic lane, keeping the pre-flip list
|
||||
/// collapse (`to_graphic_typed` serves those rows). The legacy list rows accept an
|
||||
/// unconverted producer's list value as one element, built as a native group.
|
||||
#[node_macro::node(category("General"))]
|
||||
/// Out of the catalog since the split into 'As Graphic' and 'Into Group'; the identifier stays
|
||||
/// because the registry serves the typed and unit rows under it.
|
||||
#[node_macro::node(category(""))]
|
||||
pub fn to_graphic<'e, T: graphic_types::graphic::IntoGraphicElement>(
|
||||
ctx: impl Ctx + core_types::context::ExtractArena<'e>,
|
||||
#[implementations(
|
||||
@@ -479,14 +484,22 @@ pub fn to_graphic<'e, T: graphic_types::graphic::IntoGraphicElement>(
|
||||
List<Raster<CPU>>,
|
||||
List<Raster<GPU>>,
|
||||
List<Color>,
|
||||
List<GradientStops>,
|
||||
List<Gradient>,
|
||||
List<String>,
|
||||
List<Stroke>,
|
||||
)]
|
||||
content: T,
|
||||
) -> Result<Graphic<'e>, Interrupt> {
|
||||
content.into_graphic_element(ctx.arena()).ok_or_else(|| GraphError::new("the arena is exhausted").into())
|
||||
}
|
||||
|
||||
/// Type-asserts a value to be graphical content, converting each item of other content types into its matching form.
|
||||
/// Use the 'Into Group' node instead to collect the content into a single group.
|
||||
#[node_macro::node(category("General"))]
|
||||
pub fn as_graphic<'e>(_: impl Ctx, value: Graphic<'e>) -> Graphic<'e> {
|
||||
value
|
||||
}
|
||||
|
||||
/// The elementwise `Graphic` coercion the compiler-inserted converts use: each
|
||||
/// lane's element converts on its own, so a typed source feeds a graphic input
|
||||
/// without changing the level's shape. Registered under the convert identifier.
|
||||
@@ -499,14 +512,14 @@ pub fn to_graphic_element<'e, T: graphic_types::graphic::IntoGraphicElement>(
|
||||
Raster<CPU>,
|
||||
Raster<GPU>,
|
||||
Color,
|
||||
GradientStops,
|
||||
Gradient,
|
||||
String,
|
||||
List<Graphic>,
|
||||
List<Vector>,
|
||||
List<Raster<CPU>>,
|
||||
List<Raster<GPU>>,
|
||||
List<Color>,
|
||||
List<GradientStops>,
|
||||
List<Gradient>,
|
||||
List<String>,
|
||||
)]
|
||||
content: T,
|
||||
@@ -520,7 +533,7 @@ pub fn to_graphic_element<'e, T: graphic_types::graphic::IntoGraphicElement>(
|
||||
#[node_macro::node(category(""), extent(wrap_graphic_extent))]
|
||||
pub fn to_graphic_typed<'e, T: Clone + Send + Sync + core_types::CacheHash + 'static>(
|
||||
_: impl Ctx,
|
||||
#[implementations(Vector, Raster<CPU>, Raster<GPU>, Color, GradientStops, String)] content: IList<T>,
|
||||
#[implementations(Vector, Raster<CPU>, Raster<GPU>, Color, Gradient, String)] content: IList<T>,
|
||||
) -> Result<IList<Graphic<'e>>, Interrupt> {
|
||||
let item = content.as_group_item();
|
||||
Ok(Graphic::Group(core_types::record::Group { row: None, content: item }))
|
||||
@@ -544,7 +557,7 @@ fn to_graphic_unit_extent(_content: core_types::extent::ValueIn<'_, ()>, _level:
|
||||
#[node_macro::node(category(""))]
|
||||
pub fn level_to_list<T: Clone + Send + Sync + CacheHash + dyn_any::StaticTypeSized>(
|
||||
_: impl Ctx,
|
||||
#[implementations(Graphic, Vector, Raster<CPU>, Raster<GPU>, Color, GradientStops, String)] value: IList<T>,
|
||||
#[implementations(Graphic, Vector, Raster<CPU>, Raster<GPU>, Color, Gradient, String)] value: IList<T>,
|
||||
_converter: (),
|
||||
) -> List<T> {
|
||||
let item = value.as_group_item();
|
||||
@@ -598,22 +611,22 @@ pub fn flatten_vector<T: IntoGraphicList>(_: impl Ctx, #[implementations(List<Gr
|
||||
// TODO: we stash the pre-flattened list on the output so `List<Vector>::collect_metadata` can recurse into it,
|
||||
// TODO: which conflates render output with editor metadata and forces the pre-compensation dance below.
|
||||
// TODO: The cleaner fix is to drive each layer's metadata from its own Monitor's captured `(Context, List<Graphic>)`,
|
||||
// TODO: at which point this attribute (and the equivalents in Boolean Operation, Solidify Stroke, Flatten Path,
|
||||
// TODO: at which point this attribute (and the equivalents in Boolean Operation, Solidify Stroke, Combine Paths,
|
||||
// TODO: Morph, Rasterize) become unnecessary.
|
||||
if !output.is_empty() {
|
||||
if !output.is_empty() && !is_lone_anonymous_leaf(&graphic_list) {
|
||||
// Item 0 carries a composed transform inherited from the flattened input, but the merged_layers
|
||||
// already holds the original transforms; pre-compensate by item 0's inverse so the renderer's
|
||||
// `upstream_footprint *= item_0_transform` recursion cancels out and leaves the originals intact.
|
||||
let mut graphic_list = graphic_list;
|
||||
let mut merged_layers = graphic_list;
|
||||
let item_0_transform: DAffine2 = output.attribute_cloned_or_default(ATTR_TRANSFORM, 0);
|
||||
if item_0_transform.matrix2.determinant().abs() > f64::EPSILON {
|
||||
let inverse = item_0_transform.inverse();
|
||||
for transform in graphic_list.iter_attribute_values_mut_or_default::<DAffine2>(ATTR_TRANSFORM) {
|
||||
for transform in merged_layers.iter_attribute_values_mut_or_default::<DAffine2>(ATTR_TRANSFORM) {
|
||||
*transform = inverse * *transform;
|
||||
}
|
||||
}
|
||||
|
||||
output.set_attribute(ATTR_EDITOR_MERGED_LAYERS, 0, Some(graphic_list));
|
||||
output.set_attribute(ATTR_EDITOR_MERGED_LAYERS, 0, Some(merged_layers));
|
||||
}
|
||||
|
||||
output
|
||||
@@ -631,19 +644,333 @@ pub fn flatten_color<T: IntoGraphicList>(_: impl Ctx, #[implementations(List<Gra
|
||||
content.into_flattened_list()
|
||||
}
|
||||
|
||||
/// Converts a `Graphic[]` into a `GradientStops[]` by deeply flattening any gradient content it contains, and discarding any non-gradient content.
|
||||
/// Converts a `Graphic[]` into a `Gradient[]` by deeply flattening any gradient content it contains, and discarding any non-gradient content.
|
||||
#[node_macro::node(category("General"))]
|
||||
pub fn flatten_gradient<T: IntoGraphicList>(_: impl Ctx, #[implementations(List<Graphic>, List<GradientStops>)] content: T) -> List<GradientStops> {
|
||||
pub fn flatten_gradient<T: IntoGraphicList>(_: impl Ctx, #[implementations(List<Graphic>, List<Gradient>)] content: T) -> List<Gradient> {
|
||||
content.into_flattened_list()
|
||||
}
|
||||
|
||||
/// Constructs a gradient from a `Color[]`, where the colors are evenly distributed as gradient stops across the range from 0 to 1.
|
||||
#[node_macro::node(category("Color"))]
|
||||
fn colors_to_gradient(_: impl Ctx, colors: IList<Color>) -> GradientStops {
|
||||
let stop = |position: f64, color: Color| GradientStop { position, midpoint: 0.5, color };
|
||||
match colors.len() {
|
||||
0 => GradientStops::new(vec![stop(0., Color::BLACK), stop(1., Color::BLACK)]),
|
||||
1 => GradientStops::new(vec![stop(0., colors.get(0)), stop(1., colors.get(0))]),
|
||||
total => GradientStops::new((0..total).map(|index| stop(index as f64 / (total - 1) as f64, colors.get(index)))),
|
||||
/// Constructs a gradient from a `Color[]`, where each color becomes a gradient stop. A `position` attribute on the colors places their stops along the ramp and a `midpoint` attribute skews each transition, while colors carrying neither are distributed evenly across the 0 to 1 range.
|
||||
#[node_macro::node(category("Gradient"), name("Colors to Gradient"))]
|
||||
fn colors_to_gradient<T: IntoGraphicList>(_: impl Ctx, #[implementations(List<Graphic>, List<Color>)] colors: T) -> Gradient {
|
||||
Gradient::from(colors.into_flattened_list::<Color>())
|
||||
}
|
||||
|
||||
/// Unwraps a gradient into a `Color[]` of its stops, keeping any `position` and `midpoint` attributes that place them along the ramp. Attributes belonging to the gradient as a whole (like spread and interpolation), rather than its individual color stops, are not preserved.
|
||||
#[node_macro::node(category("Gradient"), name("Gradient to Colors"))]
|
||||
fn gradient_to_colors(_: impl Ctx, gradient: Gradient) -> List<Color> {
|
||||
gradient.into_color_list()
|
||||
}
|
||||
|
||||
/// Keeps chosen items from a list (those corresponding to `true` values) and discards the others (those corresponding to `false` values) based on the *Keep Pattern* bool list. A short pattern is repeated over the remainder of the filtered list, allowing a pattern like `[true, false]` to keep every other item starting from the first. An empty pattern keeps all items.
|
||||
#[node_macro::node(category("General"))]
|
||||
fn filter<T: Send + Sync + 'static>(
|
||||
_: impl Ctx,
|
||||
/// The list of data to filter.
|
||||
#[implementations(
|
||||
List<String>,
|
||||
List<bool>,
|
||||
List<f32>,
|
||||
List<f64>,
|
||||
List<u32>,
|
||||
List<u64>,
|
||||
List<DVec2>,
|
||||
List<DAffine2>,
|
||||
List<Vector>,
|
||||
List<Graphic>,
|
||||
List<Raster<CPU>>,
|
||||
List<Raster<GPU>>,
|
||||
List<Color>,
|
||||
List<Gradient>,
|
||||
List<Artboard>,
|
||||
)]
|
||||
list: List<T>,
|
||||
/// The list of true and false values that determines which corresponding items are kept (`true`) and discarded (`false`). The pattern may repeat if it is shorter than the list of data.
|
||||
keep_pattern: List<bool>,
|
||||
) -> List<T> {
|
||||
// Tile the keep pattern over the items, so a short pattern repeats from the start
|
||||
let pattern = keep_pattern.iter_element_values().as_slice();
|
||||
if pattern.is_empty() {
|
||||
return list;
|
||||
}
|
||||
|
||||
list.into_iter().enumerate().filter_map(|(index, item)| pattern[index % pattern.len()].then_some(item)).collect()
|
||||
}
|
||||
|
||||
/// Reverses the order of the items in a list, so the last item comes first and the first comes last.
|
||||
#[node_macro::node(category("General"))]
|
||||
fn reverse<T: Send + Sync + 'static>(
|
||||
_: impl Ctx,
|
||||
/// The list of data to reverse.
|
||||
#[implementations(
|
||||
List<String>,
|
||||
List<bool>,
|
||||
List<f32>,
|
||||
List<f64>,
|
||||
List<u32>,
|
||||
List<u64>,
|
||||
List<DVec2>,
|
||||
List<DAffine2>,
|
||||
List<Vector>,
|
||||
List<Graphic>,
|
||||
List<Raster<CPU>>,
|
||||
List<Raster<GPU>>,
|
||||
List<Color>,
|
||||
List<Gradient>,
|
||||
List<Artboard>,
|
||||
)]
|
||||
list: List<T>,
|
||||
) -> List<T> {
|
||||
list.into_iter().rev().collect()
|
||||
}
|
||||
|
||||
/// Shifts the items in a list by a number of positions. With wrapping, items pushed off one end reappear at the other. Otherwise they are dropped, shortening the list.
|
||||
#[node_macro::node(category("General"))]
|
||||
fn shift<T: Send + Sync + 'static>(
|
||||
_: impl Ctx,
|
||||
/// The list of data to shift.
|
||||
#[implementations(
|
||||
List<String>,
|
||||
List<bool>,
|
||||
List<f32>,
|
||||
List<f64>,
|
||||
List<u32>,
|
||||
List<u64>,
|
||||
List<DVec2>,
|
||||
List<DAffine2>,
|
||||
List<Vector>,
|
||||
List<Graphic>,
|
||||
List<Raster<CPU>>,
|
||||
List<Raster<GPU>>,
|
||||
List<Color>,
|
||||
List<Gradient>,
|
||||
List<Artboard>,
|
||||
)]
|
||||
list: List<T>,
|
||||
/// How many positions to shift each item. Positive values shift items toward the start of the list, negative toward the end.
|
||||
amount: SignedInteger,
|
||||
/// Whether items shifted off one end wrap around to the other. When off, they are dropped and the list gets shorter.
|
||||
#[default(true)]
|
||||
wrap: bool,
|
||||
) -> List<T> {
|
||||
let amount = amount as i64;
|
||||
let len = list.len() as i64;
|
||||
if len == 0 {
|
||||
return list;
|
||||
}
|
||||
|
||||
let mut items: Vec<Item<T>> = list.into_iter().collect();
|
||||
if wrap {
|
||||
items.rotate_left((((amount % len) + len) % len) as usize);
|
||||
items.into_iter().collect()
|
||||
} else if amount >= 0 {
|
||||
items.into_iter().skip(amount.min(len) as usize).collect()
|
||||
} else {
|
||||
items.into_iter().take((len + amount).max(0) as usize).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Randomly reorders the items in a list. The same seed always produces the same ordering.
|
||||
#[node_macro::node(category("General"))]
|
||||
fn shuffle<T: Send + Sync + 'static>(
|
||||
_: impl Ctx,
|
||||
/// The list to have its items randomly reordered.
|
||||
#[implementations(
|
||||
List<String>,
|
||||
List<bool>,
|
||||
List<f32>,
|
||||
List<f64>,
|
||||
List<u32>,
|
||||
List<u64>,
|
||||
List<DVec2>,
|
||||
List<DAffine2>,
|
||||
List<Vector>,
|
||||
List<Graphic>,
|
||||
List<Raster<CPU>>,
|
||||
List<Raster<GPU>>,
|
||||
List<Color>,
|
||||
List<Gradient>,
|
||||
List<Artboard>,
|
||||
)]
|
||||
list: List<T>,
|
||||
/// Seed to determine the unique variation of the random shuffle ordering. The same seed always produces the same ordering.
|
||||
seed: SeedValue,
|
||||
) -> List<T> {
|
||||
let mut items: Vec<Item<T>> = list.into_iter().collect();
|
||||
|
||||
let mut rng = rand::rngs::StdRng::seed_from_u64(seed.into());
|
||||
items.shuffle(&mut rng);
|
||||
|
||||
items.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Generates a list of evenly spaced numbers, starting at a value and progressing by a step (which may be positive, negative, or zero) for a given count.
|
||||
#[node_macro::node(category("General"), name("Number Sequence"))]
|
||||
fn number_sequence(
|
||||
_: impl Ctx,
|
||||
_primary: (),
|
||||
/// The first number in the sequence.
|
||||
start: f64,
|
||||
/// The amount added to reach each successive number.
|
||||
#[default(1.)]
|
||||
step: f64,
|
||||
/// How many numbers to generate.
|
||||
#[default(10)]
|
||||
count: u32,
|
||||
) -> List<f64> {
|
||||
(0..count).map(|index| Item::new_from_element(start + step * index as f64)).collect()
|
||||
}
|
||||
|
||||
/// Counts out the index of each item in a list (0, 1, 2, and so on), producing a list of numbers with one for each item.
|
||||
#[node_macro::node(category("General"))]
|
||||
fn list_indices(
|
||||
_: impl Ctx,
|
||||
/// The list whose items are counted.
|
||||
list: ListDyn,
|
||||
/// The number that the count begins from for the first item.
|
||||
start_index: SignedInteger,
|
||||
) -> List<f64> {
|
||||
(0..list.len()).map(|index| Item::new_from_element(start_index + index as f64)).collect()
|
||||
}
|
||||
|
||||
/// Extracts a portion of a list, starting at "Start" and ending before "End".
|
||||
///
|
||||
/// Negative indices count from the end of the list. If the index of "Start" equals or exceeds "End", the result is an empty list.
|
||||
#[node_macro::node(category("General"))]
|
||||
fn list_slice<T: Send + Sync + 'static>(
|
||||
_: impl Ctx,
|
||||
/// The list of data to take a portion of.
|
||||
#[implementations(
|
||||
List<String>,
|
||||
List<bool>,
|
||||
List<f32>,
|
||||
List<f64>,
|
||||
List<u32>,
|
||||
List<u64>,
|
||||
List<DVec2>,
|
||||
List<DAffine2>,
|
||||
List<Vector>,
|
||||
List<Graphic>,
|
||||
List<Raster<CPU>>,
|
||||
List<Raster<GPU>>,
|
||||
List<Color>,
|
||||
List<Gradient>,
|
||||
List<Artboard>,
|
||||
)]
|
||||
list: List<T>,
|
||||
/// The index of the first item in the portion. Negative indices count from the end of the list.
|
||||
start: SignedInteger,
|
||||
/// The index the portion ends before, which is not included. Zero or negative indices count from the end of the list.
|
||||
end: SignedInteger,
|
||||
) -> List<T> {
|
||||
let total_items = list.len();
|
||||
|
||||
let start = if start < 0. {
|
||||
total_items.saturating_sub(start.abs() as usize)
|
||||
} else {
|
||||
(start as usize).min(total_items)
|
||||
};
|
||||
let end = if end <= 0. {
|
||||
total_items.saturating_sub(end.abs() as usize)
|
||||
} else {
|
||||
(end as usize).min(total_items)
|
||||
};
|
||||
|
||||
if start >= end {
|
||||
return List::new();
|
||||
}
|
||||
|
||||
list.into_iter().skip(start).take(end - start).collect()
|
||||
}
|
||||
|
||||
/// Pairwise ordering used by the Sort node for element values. Types without a natural
|
||||
/// order compare as equal, so the stable sort leaves their items in their original relative positions.
|
||||
pub trait ElementOrder {
|
||||
fn element_order(&self, _other: &Self) -> Ordering {
|
||||
Ordering::Equal
|
||||
}
|
||||
}
|
||||
impl ElementOrder for String {
|
||||
fn element_order(&self, other: &Self) -> Ordering {
|
||||
self.cmp(other)
|
||||
}
|
||||
}
|
||||
impl ElementOrder for bool {
|
||||
fn element_order(&self, other: &Self) -> Ordering {
|
||||
self.cmp(other)
|
||||
}
|
||||
}
|
||||
impl ElementOrder for f32 {
|
||||
fn element_order(&self, other: &Self) -> Ordering {
|
||||
self.total_cmp(other)
|
||||
}
|
||||
}
|
||||
impl ElementOrder for f64 {
|
||||
fn element_order(&self, other: &Self) -> Ordering {
|
||||
self.total_cmp(other)
|
||||
}
|
||||
}
|
||||
impl ElementOrder for u32 {
|
||||
fn element_order(&self, other: &Self) -> Ordering {
|
||||
self.cmp(other)
|
||||
}
|
||||
}
|
||||
impl ElementOrder for u64 {
|
||||
fn element_order(&self, other: &Self) -> Ordering {
|
||||
self.cmp(other)
|
||||
}
|
||||
}
|
||||
impl ElementOrder for DVec2 {}
|
||||
impl ElementOrder for DAffine2 {}
|
||||
impl ElementOrder for Vector {}
|
||||
impl ElementOrder for Graphic<'_> {}
|
||||
impl ElementOrder for Raster<CPU> {}
|
||||
impl ElementOrder for Raster<GPU> {}
|
||||
impl ElementOrder for Color {}
|
||||
impl ElementOrder for Gradient {}
|
||||
impl ElementOrder for Artboard<'_> {}
|
||||
|
||||
/// Reorders a list's items from smallest to largest, either by each item's own value or by a parallel list of sortable values in the *Sort Order* input. The sort is stable, so items with the same sort order retain their relative positions.
|
||||
#[node_macro::node(category("General"))]
|
||||
fn sort<T: ElementOrder + Clone + Send + Sync + 'static, U: ElementOrder + Send + Sync + 'static>(
|
||||
_: impl Ctx,
|
||||
/// The list of data to reorder.
|
||||
#[implementations(
|
||||
List<String>, List<bool>, List<f32>, List<f64>, List<u32>, List<u64>, List<DVec2>, List<DAffine2>, List<Vector>, List<Graphic>, List<Raster<CPU>>, List<Raster<GPU>>, List<Color>, List<Gradient>, List<Artboard>,
|
||||
List<String>, List<bool>, List<f32>, List<f64>, List<u32>, List<u64>, List<DVec2>, List<DAffine2>, List<Vector>, List<Graphic>, List<Raster<CPU>>, List<Raster<GPU>>, List<Color>, List<Gradient>, List<Artboard>,
|
||||
List<String>, List<bool>, List<f32>, List<f64>, List<u32>, List<u64>, List<DVec2>, List<DAffine2>, List<Vector>, List<Graphic>, List<Raster<CPU>>, List<Raster<GPU>>, List<Color>, List<Gradient>, List<Artboard>,
|
||||
)]
|
||||
list: List<T>,
|
||||
/// The optional list of orderable values, corresponding item-to-item with the input list, to sort by instead of the items' own values.
|
||||
#[expose]
|
||||
#[implementations(
|
||||
List<f64>, List<f64>, List<f64>, List<f64>, List<f64>, List<f64>, List<f64>, List<f64>, List<f64>, List<f64>, List<f64>, List<f64>, List<f64>, List<f64>, List<f64>,
|
||||
List<String>, List<String>, List<String>, List<String>, List<String>, List<String>, List<String>, List<String>, List<String>, List<String>, List<String>, List<String>, List<String>, List<String>, List<String>,
|
||||
List<bool>, List<bool>, List<bool>, List<bool>, List<bool>, List<bool>, List<bool>, List<bool>, List<bool>, List<bool>, List<bool>, List<bool>, List<bool>, List<bool>, List<bool>,
|
||||
)]
|
||||
sort_order: List<U>,
|
||||
/// Reverses the sorted list order, following descending order instead of ascending (numbers largest-to-smallest, strings Z-to-A, etc.).
|
||||
reverse: bool,
|
||||
) -> List<T> {
|
||||
// Order by the parallel keys when provided (repeating the last if there are fewer keys than items), otherwise by the element values themselves
|
||||
let keys = sort_order.iter_element_values().as_slice();
|
||||
let elements: Vec<&T> = list.iter_element_values().collect();
|
||||
|
||||
let mut order: Vec<usize> = (0..list.len()).collect();
|
||||
order.sort_by(|&a, &b| {
|
||||
let ordering = match keys {
|
||||
[] => elements[a].element_order(elements[b]),
|
||||
keys => keys[a.min(keys.len() - 1)].element_order(&keys[b.min(keys.len() - 1)]),
|
||||
};
|
||||
if reverse { ordering.reverse() } else { ordering }
|
||||
});
|
||||
|
||||
let mut result = List::new();
|
||||
for index in order {
|
||||
if let Some(item) = list.clone_item(index) {
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ use glam::DAffine2;
|
||||
use graphic_types::Vector;
|
||||
use graphic_types::graphic::Graphic;
|
||||
use raster_types::{CPU, Raster};
|
||||
use vector_types::{GradientStop, GradientStops};
|
||||
use vector_types::{GradientStop, Gradient};
|
||||
|
||||
/// Whether the walk can descend into a group: the run holds `Graphic`
|
||||
/// elements.
|
||||
@@ -39,7 +39,7 @@ pub(crate) fn group_locate<'e>(group: &core_types::record::Group<'e>, transform:
|
||||
/// itself otherwise.
|
||||
pub(crate) fn leaf_count(graphic: &Graphic, fully_flatten: bool, depth: usize) -> usize {
|
||||
match graphic {
|
||||
Graphic::Graphic(children) if fully_flatten || depth == 0 => (0..children.len())
|
||||
Graphic::GraphicList(children) if fully_flatten || depth == 0 => (0..children.len())
|
||||
.map(|index| children.element(index).map_or(0, |child| leaf_count(child, fully_flatten, depth + 1)))
|
||||
.sum(),
|
||||
Graphic::Group(group) if (fully_flatten || depth == 0) && group_expands(group) => group_leaf_count(group, fully_flatten, depth),
|
||||
@@ -51,7 +51,7 @@ pub(crate) fn leaf_count(graphic: &Graphic, fully_flatten: bool, depth: usize) -
|
||||
/// along its path composed onto `transform`.
|
||||
pub(crate) fn locate<'e>(graphic: &Graphic<'e>, transform: DAffine2, fully_flatten: bool, depth: usize, remaining: &mut usize) -> Option<(Graphic<'e>, DAffine2)> {
|
||||
match graphic {
|
||||
Graphic::Graphic(children) if fully_flatten || depth == 0 => (0..children.len()).find_map(|index| {
|
||||
Graphic::GraphicList(children) if fully_flatten || depth == 0 => (0..children.len()).find_map(|index| {
|
||||
let child = children.element(index)?;
|
||||
let child_transform: DAffine2 = children.attribute_cloned_or_default(ATTR_TRANSFORM, index);
|
||||
locate(child, transform * child_transform, fully_flatten, depth + 1, remaining)
|
||||
@@ -113,12 +113,12 @@ fn wrap_extent(_content: ListIn<'_, Graphic>, _level: LevelIn) -> GPoll<Extent>
|
||||
/// Rank-model colors-to-gradient: the color level folds into one gradient
|
||||
/// with evenly spaced stops.
|
||||
#[node_macro::node(category("Test"))]
|
||||
fn to_gradient(_: impl Ctx, colors: IList<Color>) -> GradientStops {
|
||||
fn to_gradient(_: impl Ctx, colors: IList<Color>) -> Gradient {
|
||||
let stop = |position: f64, color: Color| GradientStop { position, midpoint: 0.5, color };
|
||||
match colors.len() {
|
||||
0 => GradientStops::new(vec![stop(0., Color::BLACK), stop(1., Color::BLACK)]),
|
||||
1 => GradientStops::new(vec![stop(0., colors.get(0)), stop(1., colors.get(0))]),
|
||||
total => GradientStops::new((0..total).map(|index| stop(index as f64 / (total - 1) as f64, colors.get(index)))),
|
||||
0 => Gradient::new(vec![stop(0., Color::BLACK), stop(1., Color::BLACK)]),
|
||||
1 => Gradient::new(vec![stop(0., colors.get(0)), stop(1., colors.get(0))]),
|
||||
total => Gradient::new((0..total).map(|index| stop(index as f64 / (total - 1) as f64, colors.get(index)))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ pub(crate) fn vararg_row<Row: Clone + Send + Sync + 'static>(content: core_types
|
||||
#[node_macro::node(category("Test"))]
|
||||
fn map<Row: Clone + Send + Sync + core_types::CacheHash + 'static, T>(
|
||||
ctx: impl Ctx + DeriveCtx + ExtractIndex + InjectIndex + Copy,
|
||||
#[implementations(Graphic, Vector, Raster<CPU>, Color, GradientStops, String)] content: IList<Row>,
|
||||
#[implementations(Graphic, Vector, Raster<CPU>, Color, Gradient, String)] content: IList<Row>,
|
||||
mapped: impl Node<Context<'_>, Output = IList<T>>,
|
||||
) -> Result<IList<IList<T>>, Interrupt> {
|
||||
let mut remaining = ctx.index();
|
||||
@@ -159,7 +159,7 @@ fn map<Row: Clone + Send + Sync + core_types::CacheHash + 'static, T>(
|
||||
#[node_macro::node(category("Test"))]
|
||||
fn flat_map<Row: Clone + Send + Sync + core_types::CacheHash + 'static, T>(
|
||||
ctx: impl Ctx + DeriveCtx + ExtractIndex + InjectIndex + Copy,
|
||||
#[implementations(Graphic, Vector, Raster<CPU>, Color, GradientStops, String)] content: IList<Row>,
|
||||
#[implementations(Graphic, Vector, Raster<CPU>, Color, Gradient, String)] content: IList<Row>,
|
||||
mapped: impl Node<Context<'_>, Output = IList<T>>,
|
||||
) -> Result<IList<T>, Interrupt> {
|
||||
let mut remaining = ctx.index();
|
||||
@@ -317,7 +317,7 @@ mod tests {
|
||||
list.push(Item::new_from_element(child));
|
||||
list.set_attribute(ATTR_TRANSFORM, index, transform);
|
||||
}
|
||||
Graphic::Graphic(list)
|
||||
Graphic::GraphicList(list)
|
||||
}
|
||||
|
||||
fn text_of<'a>(graphic: &'a Graphic<'_>) -> &'a str {
|
||||
@@ -636,7 +636,7 @@ mod tests {
|
||||
let GPoll::Final(record) = record::capture(&node, &ctx.promoted(&head, 2), &frames) else {
|
||||
panic!("expected a final record");
|
||||
};
|
||||
let Graphic::Graphic(children) = record.element::<Graphic>() else {
|
||||
let Graphic::GraphicList(children) = record.element::<Graphic>() else {
|
||||
panic!("lane 2 keeps the subgroup element");
|
||||
};
|
||||
assert_eq!(children.len(), 1);
|
||||
@@ -809,14 +809,14 @@ mod tests {
|
||||
let ctx = ContextImpl::root(&scope);
|
||||
|
||||
let layout = Layout::default().with_writes(1, record::element_write_hashed::<Color>(), &[]);
|
||||
let out = Layout::default().with_writes(0, record::element_write_hashed::<GradientStops>(), &[]);
|
||||
let out = Layout::default().with_writes(0, record::element_write_hashed::<Gradient>(), &[]);
|
||||
let build = |colors: Vec<Color>| install_flip(ToGradientNode::new(RecordSource::new(ColorSource { layout: layout.clone(), colors }, &layout, &layout), &layout), &out);
|
||||
let stops_of = |colors: Vec<Color>| {
|
||||
let node = build(colors);
|
||||
let GPoll::Final(record) = record::capture(&node, &ctx, &frames) else {
|
||||
panic!("expected a final record");
|
||||
};
|
||||
record.element::<GradientStops>()
|
||||
record.element::<Gradient>()
|
||||
};
|
||||
|
||||
let three = stops_of(vec![Color::BLACK, Color::WHITE, Color::BLACK]);
|
||||
|
||||
@@ -12,7 +12,7 @@ pub use graphene_application_io as application_io;
|
||||
pub use graphene_core;
|
||||
pub use graphene_core::debug;
|
||||
pub use graphic_nodes;
|
||||
pub use graphic_types::{Artboard, Graphic, Vector};
|
||||
pub use graphic_types::{Appearance, Artboard, Cover, CoverPlacement, Coverage, Graphic, Vector, stamp_coverage};
|
||||
pub use math_nodes;
|
||||
pub use path_bool_nodes;
|
||||
pub use raster_nodes;
|
||||
@@ -32,7 +32,7 @@ pub mod vector {
|
||||
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::{PointId, SegmentId};
|
||||
pub use vector_types::vector::{deserialize_hashmap, serialize_hashmap, serialize_hashmap_as_sorted_object};
|
||||
|
||||
// Re-export HandleExt trait and NoHashBuilder
|
||||
@@ -53,12 +53,8 @@ pub mod artboard {
|
||||
pub use graphic_types::artboard::*;
|
||||
}
|
||||
|
||||
pub mod subpath {
|
||||
pub use vector_types::subpath::*;
|
||||
}
|
||||
|
||||
pub mod gradient {
|
||||
pub use vector_types::{GradientStop, GradientStops};
|
||||
pub use vector_types::{Gradient, GradientStop};
|
||||
}
|
||||
|
||||
pub mod transform {
|
||||
@@ -71,10 +67,11 @@ pub mod repeat {
|
||||
}
|
||||
|
||||
pub mod math {
|
||||
pub use core_types::math::float_noise;
|
||||
pub use core_types::math::quad;
|
||||
|
||||
pub mod math_ext {
|
||||
pub use vector_types::{QuadExt, RectExt};
|
||||
pub use vector_types::QuadExt;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ use graphic_types::markers::EditorMergedLayers;
|
||||
use graphic_types::raster_types::Image;
|
||||
use graphic_types::raster_types::{CPU, Raster};
|
||||
#[cfg(target_family = "wasm")]
|
||||
use graphic_types::vector_types::gradient::GradientStops;
|
||||
use graphic_types::vector_types::gradient::Gradient;
|
||||
#[cfg(target_family = "wasm")]
|
||||
use rendering::{Render, RenderParams, RenderSvgSegmentList, SvgRender};
|
||||
use std::sync::Arc;
|
||||
@@ -212,7 +212,7 @@ async fn rasterize<T: WasmNotSend + Clone>(
|
||||
List<Raster<CPU>>,
|
||||
List<Graphic>,
|
||||
List<Color>,
|
||||
List<GradientStops>,
|
||||
List<Gradient>,
|
||||
)]
|
||||
mut data: List<T>,
|
||||
footprint: Footprint,
|
||||
@@ -282,12 +282,17 @@ pub fn editor_api(_: impl Ctx, #[scope("editor-api")] editor_api: Arc<PlatformEd
|
||||
pub fn resource(_: impl Ctx, hash: ResourceHash, #[scope(editor_api::IDENTIFIER)] editor_api: Arc<PlatformEditorApi>) -> SourceFuture<GPoll<Resource>> {
|
||||
let application_io = editor_api.application_io.clone();
|
||||
Box::pin(async move {
|
||||
// An older document can name a resource whose bytes are gone, so hand back an empty one and keep the document loading
|
||||
let Some(application_io) = application_io else {
|
||||
return GPoll::error("ApplicationIo not available");
|
||||
log::error!("Resource {hash} is unavailable because the platform's application IO is missing");
|
||||
return GPoll::Final(Resource::empty());
|
||||
};
|
||||
match application_io.load_resource(hash).await {
|
||||
Some(resource) => GPoll::Final(resource),
|
||||
None => GPoll::error("resource not found"),
|
||||
None => {
|
||||
log::error!("Resource {hash} was not found in storage");
|
||||
GPoll::Final(Resource::empty())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,8 +8,7 @@ use graph_craft::document::value::{RenderOutput, RenderOutputType};
|
||||
use graphic_types::raster_types::Texture;
|
||||
use rendering::{RenderParams, SvgRender, SvgRenderOutput};
|
||||
use std::fmt::Write;
|
||||
use wgpu::util::DeviceExt;
|
||||
use wgpu_executor::{WgpuExecutor, WgpuPipeline, WgpuPipelineCache};
|
||||
use wgpu_executor::{Buffer, WgpuExecutor, WgpuPipeline, WgpuPipelineCache};
|
||||
|
||||
#[node_macro::node(category(""))]
|
||||
fn render_background(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, #[scope(composite_background_pipeline::IDENTIFIER)] pipeline: WgpuPipelineCache, data: RenderOutput) -> RenderOutput {
|
||||
@@ -352,10 +351,8 @@ impl WgpuPipeline for CompositeBackground {
|
||||
let foreground_view = foreground.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
|
||||
let checker_draws = if backgrounds.is_empty() {
|
||||
vec![(
|
||||
3,
|
||||
self.create_checker_bind_group(device, CompositeUniforms::fullscreen(viewport_size, screen_to_document, checker_size_doc)),
|
||||
)]
|
||||
let uniforms = CompositeUniforms::fullscreen(viewport_size, screen_to_document, checker_size_doc).create_buffer(executor);
|
||||
vec![(3, self.create_checker_bind_group(device, &uniforms), uniforms)]
|
||||
} else {
|
||||
backgrounds
|
||||
.iter()
|
||||
@@ -370,8 +367,8 @@ impl WgpuPipeline for CompositeBackground {
|
||||
return None;
|
||||
}
|
||||
|
||||
let uniforms = CompositeUniforms::rect(min, max, document_to_screen, viewport_size, checker_size_doc);
|
||||
Some((6, self.create_checker_bind_group(device, uniforms)))
|
||||
let uniforms = CompositeUniforms::rect(min, max, document_to_screen, viewport_size, checker_size_doc).create_buffer(executor);
|
||||
Some((6, self.create_checker_bind_group(device, &uniforms), uniforms))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
@@ -413,13 +410,13 @@ impl WgpuPipeline for CompositeBackground {
|
||||
|
||||
if backgrounds.is_empty() {
|
||||
pass.set_pipeline(&self.checker_viewport_pipeline);
|
||||
for (vertex_count, bind_group) in &checker_draws {
|
||||
for (vertex_count, bind_group, _uniforms) in &checker_draws {
|
||||
pass.set_bind_group(0, bind_group, &[]);
|
||||
pass.draw(0..*vertex_count, 0..1);
|
||||
}
|
||||
} else {
|
||||
pass.set_pipeline(&self.checker_rect_pipeline);
|
||||
for (vertex_count, bind_group) in &checker_draws {
|
||||
for (vertex_count, bind_group, _uniforms) in &checker_draws {
|
||||
pass.set_bind_group(0, bind_group, &[]);
|
||||
pass.draw(0..*vertex_count, 0..1);
|
||||
}
|
||||
@@ -437,19 +434,13 @@ impl WgpuPipeline for CompositeBackground {
|
||||
}
|
||||
|
||||
impl CompositeBackground {
|
||||
fn create_checker_bind_group(&self, device: &wgpu::Device, uniforms: CompositeUniforms) -> wgpu::BindGroup {
|
||||
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("background_checker_uniforms"),
|
||||
contents: bytemuck::bytes_of(&uniforms),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
|
||||
fn create_checker_bind_group(&self, device: &wgpu::Device, uniforms: &Buffer) -> wgpu::BindGroup {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("background_checker_bind_group"),
|
||||
layout: &self.checker_bind_group_layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: buffer.as_entire_binding(),
|
||||
resource: uniforms.as_entire_binding(),
|
||||
}],
|
||||
})
|
||||
}
|
||||
@@ -491,4 +482,12 @@ impl CompositeUniforms {
|
||||
_pad: 0.,
|
||||
}
|
||||
}
|
||||
|
||||
fn create_buffer(&self, executor: &WgpuExecutor) -> Buffer {
|
||||
executor.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("background_checker_uniforms"),
|
||||
contents: bytemuck::bytes_of(self),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ pub struct CacheKey {
|
||||
pub for_mask: bool,
|
||||
pub thumbnail: bool,
|
||||
pub aligned_strokes: bool,
|
||||
pub override_paint_order: bool,
|
||||
pub stroke_below: bool,
|
||||
pub animation_time_ms: i64,
|
||||
pub real_time_ms: i64,
|
||||
pub pointer: [u8; 16],
|
||||
@@ -60,7 +60,7 @@ impl CacheKey {
|
||||
for_mask: bool,
|
||||
thumbnail: bool,
|
||||
aligned_strokes: bool,
|
||||
override_paint_order: bool,
|
||||
stroke_below: bool,
|
||||
animation_time: f64,
|
||||
real_time: f64,
|
||||
pointer: Option<DVec2>,
|
||||
@@ -85,7 +85,7 @@ impl CacheKey {
|
||||
for_mask,
|
||||
thumbnail,
|
||||
aligned_strokes,
|
||||
override_paint_order,
|
||||
stroke_below,
|
||||
animation_time_ms: (animation_time * 1000.).round() as i64,
|
||||
real_time_ms: (real_time * 1000.).round() as i64,
|
||||
pointer: pointer_bytes,
|
||||
@@ -360,7 +360,7 @@ pub fn render_output_cache(
|
||||
render_params.for_mask,
|
||||
render_params.thumbnail,
|
||||
render_params.aligned_strokes,
|
||||
render_params.override_paint_order,
|
||||
render_params.stroke_below,
|
||||
ctx.try_animation_time().unwrap_or(0.),
|
||||
ctx.try_real_time().unwrap_or(0.),
|
||||
ctx.try_pointer_position(),
|
||||
|
||||
@@ -8,7 +8,7 @@ use graphic_types::raster_types::{CPU, Raster};
|
||||
use graphic_types::{Artboard, Graphic, Vector};
|
||||
use rendering::{Render, RenderMetadata, RenderOutputType as RenderOutputTypeRequest, RenderParams, SvgRender, SvgRenderOutput};
|
||||
use std::sync::Arc;
|
||||
use vector_types::GradientStops;
|
||||
use vector_types::Gradient;
|
||||
use wgpu_executor::RenderContext;
|
||||
|
||||
#[derive(Clone, dyn_any::DynAny)]
|
||||
@@ -25,7 +25,7 @@ pub struct RenderIntermediate {
|
||||
fn intermediate_of<R: Render>(data: &R, render_params: &RenderParams) -> RenderIntermediate {
|
||||
let footprint = Footprint::default();
|
||||
let mut metadata = RenderMetadata::default();
|
||||
data.collect_metadata(&mut metadata, footprint, None);
|
||||
data.collect_metadata(&mut metadata, footprint, None, None);
|
||||
match &render_params.render_output_type {
|
||||
RenderOutputTypeRequest::Vello => {
|
||||
let mut scene = vello::Scene::new();
|
||||
@@ -60,7 +60,7 @@ fn render_intermediate<T: dyn_any::StaticTypeSized + 'static + Render + WasmNotS
|
||||
Context -> List<Vector>,
|
||||
Context -> List<Raster<CPU>>,
|
||||
Context -> List<Color>,
|
||||
Context -> List<GradientStops>,
|
||||
Context -> List<Gradient>,
|
||||
Context -> List<String>,
|
||||
)]
|
||||
data: impl Node<Context<'_>, Output = T>,
|
||||
@@ -80,7 +80,7 @@ fn render_intermediate<T: dyn_any::StaticTypeSized + 'static + Render + WasmNotS
|
||||
#[node_macro::node(category(""))]
|
||||
fn render_intermediate_leveled<T: Clone + Send + Sync + core_types::CacheHash + dyn_any::StaticTypeSized + 'static>(
|
||||
ctx: impl Ctx + ExtractVarArgs + ExtractIndex + InjectIndex + Copy,
|
||||
#[implementations(Artboard, Graphic, Vector, Raster<CPU>, Color, GradientStops, String)] data: IList<T>,
|
||||
#[implementations(Artboard, Graphic, Vector, Raster<CPU>, Color, Gradient, String)] data: IList<T>,
|
||||
) -> Result<RenderIntermediate, Interrupt>
|
||||
where
|
||||
for<'a> core_types::record::RunView<'a, T>: Render,
|
||||
|
||||
@@ -5,7 +5,7 @@ use graph_craft::application_io::resource::Resource;
|
||||
use graphic_types::Vector;
|
||||
pub use text_nodes::*;
|
||||
|
||||
/// Produces a styled `String[]` carrying all typographic attributes.
|
||||
/// Produces a styled text string carrying all typographic attributes.
|
||||
///
|
||||
/// Use the **Text to Vector** node to convert this into vector geometry if desired.
|
||||
#[node_macro::node(category("Text"))]
|
||||
@@ -15,15 +15,15 @@ fn text(
|
||||
/// The text content to be drawn.
|
||||
#[widget(ParsedWidgetOverride::Custom = "text_area")]
|
||||
#[default("Lorem ipsum")]
|
||||
text: String,
|
||||
text: Item<String>,
|
||||
/// The loaded font file used to draw the text. The editor resolves the chosen typeface to these bytes via the resource system.
|
||||
#[widget(ParsedWidgetOverride::Custom = "text_font")]
|
||||
font: Resource,
|
||||
font: Item<Resource>,
|
||||
/// The font size used to draw the text.
|
||||
#[unit(" px")]
|
||||
#[default(24.)]
|
||||
#[hard(1..)]
|
||||
size: f64,
|
||||
size: Item<f64>,
|
||||
/// The line height ratio, relative to the font size. Each line is drawn lower than its previous line by the distance of *Size* × *Line Height*.
|
||||
///
|
||||
/// 0 means all lines overlap. 1 means all lines are spaced by just the font size. 1.2 is a common default for readable text. 2 means double-spaced text.
|
||||
@@ -31,74 +31,87 @@ fn text(
|
||||
#[hard(0..)]
|
||||
#[step(0.1)]
|
||||
#[default(1.2)]
|
||||
line_height: f64,
|
||||
line_height: Item<f64>,
|
||||
/// Additional spacing, in pixels, added between each character.
|
||||
#[unit(" px")]
|
||||
#[step(0.1)]
|
||||
letter_spacing: f64,
|
||||
letter_spacing: Item<f64>,
|
||||
/// The angle of faux italic slant applied to each glyph.
|
||||
#[unit("°")]
|
||||
#[hard(-85..85)]
|
||||
letter_tilt: f64,
|
||||
letter_tilt: Item<f64>,
|
||||
/// Enables the maximum width constraint so lines can wrap.
|
||||
#[widget(ParsedWidgetOverride::Hidden)]
|
||||
has_max_width: bool,
|
||||
has_max_width: Item<bool>,
|
||||
/// The maximum width that the text block can occupy before wrapping to a new line. Otherwise, lines do not wrap.
|
||||
#[unit(" px")]
|
||||
#[hard(1..)]
|
||||
#[widget(ParsedWidgetOverride::Custom = "optional_f64")]
|
||||
max_width: f64,
|
||||
max_width: Item<f64>,
|
||||
/// Whether the *Max Height* property is enabled so that lines beyond it are not drawn.
|
||||
#[widget(ParsedWidgetOverride::Hidden)]
|
||||
has_max_height: bool,
|
||||
has_max_height: Item<bool>,
|
||||
/// The maximum height that the text block can occupy. Excess lines are not drawn.
|
||||
#[unit(" px")]
|
||||
#[hard(1..)]
|
||||
#[widget(ParsedWidgetOverride::Custom = "optional_f64")]
|
||||
max_height: f64,
|
||||
max_height: Item<f64>,
|
||||
/// The horizontal alignment of each line of text within its surrounding box. To have an effect on a single line of text, *Max Width* must be set.
|
||||
#[widget(ParsedWidgetOverride::Custom = "text_align")]
|
||||
align: TextAlign,
|
||||
) -> List<String> {
|
||||
let mut list = List::new_from_element(text);
|
||||
align: Item<TextAlign>,
|
||||
) -> Item<String> {
|
||||
let text = text.into_element();
|
||||
let font = font.into_element();
|
||||
let (size, line_height, letter_spacing, letter_tilt) = (*size.element(), *line_height.element(), *letter_spacing.element(), *letter_tilt.element());
|
||||
let (has_max_width, max_width, has_max_height, max_height) = (*has_max_width.element(), *max_width.element(), *has_max_height.element(), *max_height.element());
|
||||
let align = align.into_element();
|
||||
|
||||
let mut item = Item::new_from_element(text);
|
||||
|
||||
if font != Resource::default() {
|
||||
list.set_attribute(ATTR_FONT, 0, font);
|
||||
item.set_attribute(ATTR_FONT, font);
|
||||
}
|
||||
if (size - DEFAULT_FONT_SIZE).abs() > f64::EPSILON {
|
||||
list.set_attribute(ATTR_FONT_SIZE, 0, size);
|
||||
item.set_attribute(ATTR_FONT_SIZE, size);
|
||||
}
|
||||
if (line_height - DEFAULT_LINE_HEIGHT).abs() > f64::EPSILON {
|
||||
list.set_attribute(ATTR_LINE_HEIGHT, 0, line_height);
|
||||
item.set_attribute(ATTR_LINE_HEIGHT, line_height);
|
||||
}
|
||||
if letter_spacing != 0. {
|
||||
list.set_attribute(ATTR_LETTER_SPACING, 0, letter_spacing);
|
||||
item.set_attribute(ATTR_LETTER_SPACING, letter_spacing);
|
||||
}
|
||||
if letter_tilt != 0. {
|
||||
list.set_attribute(ATTR_LETTER_TILT, 0, letter_tilt);
|
||||
item.set_attribute(ATTR_LETTER_TILT, letter_tilt);
|
||||
}
|
||||
if has_max_width {
|
||||
list.set_attribute(ATTR_MAX_WIDTH, 0, Some(max_width));
|
||||
item.set_attribute(ATTR_MAX_WIDTH, Some(max_width));
|
||||
}
|
||||
if has_max_height {
|
||||
list.set_attribute(ATTR_MAX_HEIGHT, 0, Some(max_height));
|
||||
item.set_attribute(ATTR_MAX_HEIGHT, Some(max_height));
|
||||
}
|
||||
if align != TextAlign::default() {
|
||||
list.set_attribute(ATTR_TEXT_ALIGN, 0, align);
|
||||
item.set_attribute(ATTR_TEXT_ALIGN, align);
|
||||
}
|
||||
|
||||
list
|
||||
item
|
||||
}
|
||||
|
||||
/// Converts a styled `String[]` into vector geometry.
|
||||
/// Converts a styled text string into a vector compound path.
|
||||
#[node_macro::node(category("Text"), name("Text to Vector"))]
|
||||
fn text_to_vector(
|
||||
_: impl Ctx,
|
||||
/// A styled list of text strings produced by the **Text** node (or any other `String[]` source).
|
||||
#[implementations(List<String>)]
|
||||
strings: List<String>,
|
||||
/// Whether to split every letterform into its own vector item. Otherwise, a single vector compound path is produced.
|
||||
separate_glyphs: bool,
|
||||
) -> List<Vector> {
|
||||
shape_text_list(&strings, separate_glyphs)
|
||||
/// A styled text string produced by the **Text** node (or any other string source).
|
||||
string: Item<String>,
|
||||
) -> Item<Vector> {
|
||||
shape_text_item(&string, false).into_iter().next().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Splits a styled text string into a separate vector item for each of its glyphs (letterforms).
|
||||
#[node_macro::node(category("Text"), name("Text to Vector Glyphs"))]
|
||||
fn text_to_vector_glyphs(
|
||||
_: impl Ctx,
|
||||
/// A styled text string produced by the **Text** node (or any other string source).
|
||||
string: Item<String>,
|
||||
) -> List<Vector> {
|
||||
shape_text_item(&string, true)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,17 +6,16 @@ use glam::{DAffine2, DVec2};
|
||||
use graphic_types::graphic::{GraphicLevel, PaintColumns, PaintReach, bake_paint_transforms, is_paint_present, set_paint_attribute, set_paint_attribute_at};
|
||||
use graphic_types::markers::{EditorMergedLayers, Fill, Stroke};
|
||||
use graphic_types::raster_types::{CPU, GPU, Raster};
|
||||
use graphic_types::vector_types::GradientStops;
|
||||
use graphic_types::vector_types::gradient::{GradientSpreadMethod, GradientType};
|
||||
use graphic_types::vector_types::subpath::{ManipulatorGroup, Subpath};
|
||||
use graphic_types::vector_types::vector::PointId;
|
||||
use graphic_types::vector_types::markers::{ATTR_GRADIENT_FORM, ATTR_GRADIENT_SPREAD};
|
||||
use graphic_types::vector_types::vector::VectorExt;
|
||||
use graphic_types::vector_types::vector::algorithms::merge_by_distance::MergeByDistanceExt;
|
||||
use graphic_types::vector_types::{ATTR_GRADIENT_TYPE, ATTR_SPREAD_METHOD};
|
||||
use graphic_types::vector_types::vector::algorithms::shapes::rectangle_bezpath;
|
||||
use graphic_types::vector_types::{Gradient, GradientForm, GradientSpread};
|
||||
use graphic_types::{ATTR_FILL, ATTR_STROKE, Graphic, IntoGraphicList, Vector};
|
||||
use linesweeper::topology::Topology;
|
||||
use linesweeper::{BinaryOp, FillRule, binary_op};
|
||||
use smallvec::SmallVec;
|
||||
use vector_types::kurbo::{Affine, BezPath, CubicBez, Line, ParamCurve, PathSeg, Point, QuadBez};
|
||||
use vector_types::kurbo::{Affine, BezPath, CubicBez, Line, ParamCurve, PathEl, PathSeg, Point, QuadBez};
|
||||
pub use vector_types::vector::misc::BooleanOperation;
|
||||
|
||||
// TODO: Fix boolean ops to work by removing .transform() and .one_instance_*() calls,
|
||||
@@ -44,6 +43,8 @@ fn boolean_core<'e>(
|
||||
),
|
||||
core_types::gpoll::Interrupt,
|
||||
> {
|
||||
use core_types::lane::LaneSource;
|
||||
|
||||
// The first index is the bottom of the stack
|
||||
let mut result_vector_list = boolean_operation_on_vector_list(&flattened, operation);
|
||||
|
||||
@@ -54,7 +55,15 @@ fn boolean_core<'e>(
|
||||
|
||||
let result_vector = result_vector_list.element_mut(0).unwrap();
|
||||
Vector::transform(result_vector, transform);
|
||||
result_vector.set_stroke_transform(DAffine2::IDENTITY);
|
||||
|
||||
// The geometry is baked into identity space, so a copied paint authoring space would be stale.
|
||||
// Master drops it off each `Appearance` coverage; our paint rides `ATTR_FILL`/`ATTR_STROKE`, so it drops off there.
|
||||
let stale_paint = [(ATTR_FILL, result_vector_list.attr::<Fill>(0).cloned()), (ATTR_STROKE, result_vector_list.attr::<Stroke>(0).cloned())];
|
||||
for (key, paint) in stale_paint {
|
||||
let Some(mut paint) = paint else { continue };
|
||||
paint.remove_attribute(ATTR_TRANSFORM);
|
||||
set_paint_attribute_at(&mut result_vector_list, 0, key, paint);
|
||||
}
|
||||
|
||||
// Clean up the boolean operation result by merging duplicated points
|
||||
let merge_transform: DAffine2 = result_vector_list.attribute_cloned_or_default(ATTR_TRANSFORM, 0);
|
||||
@@ -75,7 +84,6 @@ fn boolean_core<'e>(
|
||||
};
|
||||
|
||||
let element = result_vector_list.element(0).cloned().unwrap_or_default();
|
||||
use core_types::lane::LaneSource;
|
||||
let fill = park_paint(result_vector_list.attr::<Fill>(0).filter(|paint| is_paint_present(paint)).cloned())?;
|
||||
let stroke = park_paint(result_vector_list.attr::<Stroke>(0).filter(|paint| is_paint_present(paint)).cloned())?;
|
||||
let layer_path: Vec<NodeId> = result_vector_list.attribute::<Vec<NodeId>>(ATTR_EDITOR_LAYER_PATH, 0).cloned().unwrap_or_default();
|
||||
@@ -244,12 +252,7 @@ fn boolean_operation_on_vector_list(vector: &List<Vector>, boolean_operation: Bo
|
||||
|
||||
bake_paint_transforms(&mut attributes, copy_from_transform);
|
||||
|
||||
let copy_from = vector.element(index).unwrap();
|
||||
let element = Vector {
|
||||
stroke: copy_from.stroke.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
Item::from_parts(element, attributes)
|
||||
Item::from_parts(Vector::default(), attributes)
|
||||
} else {
|
||||
Item::<Vector>::default()
|
||||
};
|
||||
@@ -268,8 +271,8 @@ fn boolean_operation_on_vector_list(vector: &List<Vector>, boolean_operation: Bo
|
||||
}
|
||||
};
|
||||
let contours = top.contours(|winding| winding.is_inside(boolean_operation));
|
||||
for subpath in from_bez_paths(contours.contours().map(|c| &c.path)) {
|
||||
row.element_mut().append_subpath(subpath, false);
|
||||
for contour in contours.contours() {
|
||||
row.element_mut().append_bezpath(closed(contour.path.clone()));
|
||||
}
|
||||
|
||||
list.push(row);
|
||||
@@ -289,10 +292,10 @@ fn raster_stand_in_rows<S: core_types::lane::LaneSource>(image: &S, parent_trans
|
||||
let fill: f64 = image.attr::<OpacityFill>(i);
|
||||
let clip: bool = image.attr::<ClippingMask>(i);
|
||||
|
||||
let mut subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE);
|
||||
subpath.apply_transform(parent_transform * row_transform);
|
||||
let mut bezpath = rectangle_bezpath(DVec2::ZERO, DVec2::ONE);
|
||||
bezpath.apply_affine(Affine::new((parent_transform * row_transform).to_cols_array()));
|
||||
|
||||
let element = Vector::from_subpath(subpath);
|
||||
let element = Vector::from_bezpath(bezpath);
|
||||
|
||||
let mut item = Item::new_from_element(element)
|
||||
.with_attribute(ATTR_BLEND_MODE, blend_mode)
|
||||
@@ -311,31 +314,25 @@ fn raster_stand_in_rows<S: core_types::lane::LaneSource>(image: &S, parent_trans
|
||||
fn color_paint_row(color: Color, mut attributes: core_types::list::ItemAttributeValues) -> Item<Vector> {
|
||||
set_paint_attribute(&mut attributes, ATTR_FILL, List::new_from_element(color));
|
||||
|
||||
let mut element = Vector::default();
|
||||
element.set_stroke_transform(DAffine2::IDENTITY);
|
||||
|
||||
Item::from_parts(element, attributes)
|
||||
Item::from_parts(Vector::default(), attributes)
|
||||
}
|
||||
|
||||
/// A gradient row: an empty vector carrying the stops as its fill paint, the
|
||||
/// gradient keys moved onto the paint.
|
||||
fn gradient_paint_row(stops: GradientStops, mut attributes: core_types::list::ItemAttributeValues) -> Item<Vector> {
|
||||
let mut gradient_paint = List::new_from_element(Graphic::Gradient(stops));
|
||||
fn gradient_paint_row(gradient: Gradient, mut attributes: core_types::list::ItemAttributeValues) -> Item<Vector> {
|
||||
let mut gradient_paint = List::new_from_element(Graphic::Gradient(gradient));
|
||||
if let Some(transform) = attributes.remove::<DAffine2>(ATTR_TRANSFORM) {
|
||||
gradient_paint.set_attribute(ATTR_TRANSFORM, 0, transform);
|
||||
}
|
||||
if let Some(gradient_type) = attributes.remove::<GradientType>(ATTR_GRADIENT_TYPE) {
|
||||
gradient_paint.set_attribute(ATTR_GRADIENT_TYPE, 0, gradient_type);
|
||||
if let Some(gradient_form) = attributes.remove::<GradientForm>(ATTR_GRADIENT_FORM) {
|
||||
gradient_paint.set_attribute(ATTR_GRADIENT_FORM, 0, gradient_form);
|
||||
}
|
||||
if let Some(spread_method) = attributes.remove::<GradientSpreadMethod>(ATTR_SPREAD_METHOD) {
|
||||
gradient_paint.set_attribute(ATTR_SPREAD_METHOD, 0, spread_method);
|
||||
if let Some(gradient_spread) = attributes.remove::<GradientSpread>(ATTR_GRADIENT_SPREAD) {
|
||||
gradient_paint.set_attribute(ATTR_GRADIENT_SPREAD, 0, gradient_spread);
|
||||
}
|
||||
attributes.insert(ATTR_FILL, Some(gradient_paint));
|
||||
|
||||
let mut element = Vector::default();
|
||||
element.set_stroke_transform(DAffine2::IDENTITY);
|
||||
|
||||
Item::from_parts(element, attributes)
|
||||
Item::from_parts(Vector::default(), attributes)
|
||||
}
|
||||
|
||||
/// A text lane's rows: the shaped glyph vectors under the composed transform.
|
||||
@@ -413,7 +410,7 @@ fn flatten_vector_run_into<'a>(out: &mut List<Vector>, level: GraphicLevel<'a>,
|
||||
let composed = transform * level.attr::<TransformAttr>(index);
|
||||
match element {
|
||||
Graphic::Vector(vector) => push_leaf_vector_row(out, level, index, vector, transform, reach),
|
||||
Graphic::Graphic(children) => push_union(out, flatten_vector_run(GraphicLevel::Legacy(children), composed, reach.nested())),
|
||||
Graphic::GraphicList(children) => push_union(out, flatten_vector_run(GraphicLevel::Legacy(children), composed, reach.nested())),
|
||||
Graphic::Group(group) => flatten_group(out, group, composed, reach),
|
||||
Graphic::RasterCPU(raster) => push_rows(out, raster_stand_in_rows(&LeafLane::new(&level, index, raster), transform)),
|
||||
Graphic::RasterGPU(raster) => push_rows(out, raster_stand_in_rows(&LeafLane::new(&level, index, raster), transform)),
|
||||
@@ -423,6 +420,8 @@ fn flatten_vector_run_into<'a>(out: &mut List<Vector>, level: GraphicLevel<'a>,
|
||||
let one = List::new_from_item(Item::from_parts(text.clone(), graphic_types::graphic::lane_attributes(level, index)));
|
||||
push_rows(out, text_rows(&one, composed));
|
||||
}
|
||||
// Brush strokes have no vector outline; a brush node renders them to rasters
|
||||
Graphic::Stroke(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -445,7 +444,7 @@ fn flatten_group(out: &mut List<Vector>, group: &core_types::record::Group, comp
|
||||
out,
|
||||
(0..color.len()).filter_map(|i| Some(color_paint_row(*color.element(i)?, color.clone_item_attributes(i)))).collect(),
|
||||
);
|
||||
} else if let Some(gradient) = graphic_types::graphic::run_to_list::<GradientStops>(item) {
|
||||
} else if let Some(gradient) = graphic_types::graphic::run_to_list::<Gradient>(item) {
|
||||
push_rows(
|
||||
out,
|
||||
(0..gradient.len())
|
||||
@@ -473,65 +472,36 @@ fn quantize_segment(seg: PathSeg) -> PathSeg {
|
||||
}
|
||||
}
|
||||
|
||||
fn to_bez_path(vector: &Vector, transform: DAffine2) -> BezPath {
|
||||
let mut path = BezPath::new();
|
||||
for subpath in vector.stroke_bezier_paths() {
|
||||
push_subpath(&mut path, &subpath, transform);
|
||||
/// Every operand and result region is treated as closed, so an open path gets its closing segment here.
|
||||
fn closed(mut path: BezPath) -> BezPath {
|
||||
if !path.elements().is_empty() && path.elements().last() != Some(&PathEl::ClosePath) {
|
||||
path.close_path();
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
fn push_subpath(path: &mut BezPath, subpath: &Subpath<PointId>, transform: DAffine2) {
|
||||
fn to_bez_path(vector: &Vector, transform: DAffine2) -> BezPath {
|
||||
let transform = Affine::new(transform.to_cols_array());
|
||||
let mut first = true;
|
||||
let mut path = BezPath::new();
|
||||
|
||||
for seg in subpath.iter_closed() {
|
||||
let quantized = quantize_segment(transform * seg);
|
||||
if first {
|
||||
first = false;
|
||||
path.move_to(quantized.start());
|
||||
for subpath in vector.stroke_bezpath_iter() {
|
||||
let mut first = true;
|
||||
|
||||
for segment in closed(subpath).segments() {
|
||||
let quantized = quantize_segment(transform * segment);
|
||||
if first {
|
||||
first = false;
|
||||
path.move_to(quantized.start());
|
||||
}
|
||||
path.push(quantized.as_path_el());
|
||||
}
|
||||
path.push(quantized.as_path_el());
|
||||
}
|
||||
path.close_path();
|
||||
}
|
||||
|
||||
fn from_bez_paths<'a>(paths: impl Iterator<Item = &'a BezPath>) -> Vec<Subpath<PointId>> {
|
||||
let mut all_subpaths = Vec::new();
|
||||
|
||||
for path in paths {
|
||||
let cubics: Vec<CubicBez> = path.segments().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 d = |p: Point| DVec2::new(p.x, p.y);
|
||||
let [start, handle1, handle2, end] = [d(cubic.p0), d(cubic.p1), d(cubic.p2), d(cubic.p3)];
|
||||
|
||||
if current_start.is_none() {
|
||||
// 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
|
||||
}
|
||||
if !first {
|
||||
path.close_path();
|
||||
}
|
||||
}
|
||||
|
||||
all_subpaths
|
||||
path
|
||||
}
|
||||
|
||||
pub fn boolean_intersect(a: &BezPath, b: &BezPath) -> Vec<BezPath> {
|
||||
@@ -550,7 +520,7 @@ mod tests {
|
||||
use core_types::record::Group;
|
||||
|
||||
fn square(corner: DVec2) -> Vector {
|
||||
Vector::from_subpath(Subpath::<PointId>::new_rectangle(corner, corner + DVec2::ONE))
|
||||
Vector::from_bezpath(rectangle_bezpath(corner, corner + DVec2::ONE))
|
||||
}
|
||||
|
||||
fn black_paint() -> List<Graphic<'static>> {
|
||||
|
||||
@@ -13,7 +13,7 @@ impl Adjust<Color> for Color {
|
||||
mod adjust_std {
|
||||
use super::*;
|
||||
use raster_types::{CPU, Raster};
|
||||
use vector_types::GradientStops;
|
||||
use vector_types::Gradient;
|
||||
|
||||
impl Adjust<Color> for Raster<CPU> {
|
||||
fn adjust(&mut self, map_fn: impl Fn(&Color) -> Color) {
|
||||
@@ -22,11 +22,9 @@ mod adjust_std {
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Adjust<Color> for GradientStops {
|
||||
impl Adjust<Color> for Gradient {
|
||||
fn adjust(&mut self, map_fn: impl Fn(&Color) -> Color) {
|
||||
for color in self.color.iter_mut() {
|
||||
*color = map_fn(color);
|
||||
}
|
||||
*self = self.map_colors(map_fn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ use core::fmt::Debug;
|
||||
use glam::Vec3;
|
||||
use no_std_types::color::{Color, linear_to_srgb, srgb_to_linear};
|
||||
use no_std_types::context::Ctx;
|
||||
#[cfg(not(feature = "std"))]
|
||||
use no_std_types::list::ShaderItem as Item;
|
||||
use no_std_types::registry::types::{AngleF32, PercentageF32, SignedPercentageF32};
|
||||
use node_macro::BufferStruct;
|
||||
use num_enum::{FromPrimitive, IntoPrimitive};
|
||||
@@ -14,7 +16,7 @@ use num_traits::float::Float;
|
||||
#[cfg(feature = "std")]
|
||||
use raster_types::{CPU, Raster};
|
||||
#[cfg(feature = "std")]
|
||||
use vector_types::GradientStops;
|
||||
use vector_types::Gradient;
|
||||
|
||||
// TODO: Implement the following:
|
||||
// Color Balance
|
||||
@@ -56,10 +58,13 @@ fn luminance<T: Adjust<Color> + Clone + Send + Sync + no_std_types::context::Cac
|
||||
GradientStops,
|
||||
)]
|
||||
#[gpu_image]
|
||||
mut input: T,
|
||||
luminance_calc: LuminanceCalculation,
|
||||
) -> T {
|
||||
input.adjust(|color| {
|
||||
input: Item<T>,
|
||||
luminance_calc: Item<LuminanceCalculation>,
|
||||
) -> Item<T> {
|
||||
let mut input = input;
|
||||
let luminance_calc = luminance_calc.into_element();
|
||||
|
||||
input.element_mut().adjust(|color| {
|
||||
let luminance = match luminance_calc {
|
||||
LuminanceCalculation::SRGB => color.luminance_rec_709(),
|
||||
LuminanceCalculation::Perceptual => color.luminance_perceptual(),
|
||||
@@ -81,16 +86,20 @@ fn gamma_correction<T: Adjust<Color> + Clone + Send + Sync + no_std_types::conte
|
||||
GradientStops,
|
||||
)]
|
||||
#[gpu_image]
|
||||
mut input: T,
|
||||
input: Item<T>,
|
||||
#[default(2.2)]
|
||||
#[range]
|
||||
#[hard(0.0001..)]
|
||||
#[soft(0.01..10)]
|
||||
gamma: f32,
|
||||
inverse: bool,
|
||||
) -> T {
|
||||
gamma: Item<f32>,
|
||||
inverse: Item<bool>,
|
||||
) -> Item<T> {
|
||||
let mut input = input;
|
||||
let gamma = gamma.into_element();
|
||||
let inverse = inverse.into_element();
|
||||
|
||||
let exponent = if inverse { 1. / gamma } else { gamma };
|
||||
input.adjust(|color| color.apply_gamma_exponent(exponent));
|
||||
input.element_mut().adjust(|color| color.apply_gamma_exponent(exponent));
|
||||
input
|
||||
}
|
||||
|
||||
@@ -103,10 +112,13 @@ fn extract_channel<T: Adjust<Color> + Clone + Send + Sync + no_std_types::contex
|
||||
GradientStops,
|
||||
)]
|
||||
#[gpu_image]
|
||||
mut input: T,
|
||||
channel: RedGreenBlueAlpha,
|
||||
) -> T {
|
||||
input.adjust(|color| {
|
||||
input: Item<T>,
|
||||
channel: Item<RedGreenBlueAlpha>,
|
||||
) -> Item<T> {
|
||||
let mut input = input;
|
||||
let channel = channel.into_element();
|
||||
|
||||
input.element_mut().adjust(|color| {
|
||||
let extracted_value = match channel {
|
||||
RedGreenBlueAlpha::Red => color.r(),
|
||||
RedGreenBlueAlpha::Green => color.g(),
|
||||
@@ -127,9 +139,10 @@ fn make_opaque<T: Adjust<Color> + Clone + Send + Sync + no_std_types::context::C
|
||||
GradientStops,
|
||||
)]
|
||||
#[gpu_image]
|
||||
mut input: T,
|
||||
) -> T {
|
||||
input.adjust(|color| {
|
||||
input: Item<T>,
|
||||
) -> Item<T> {
|
||||
let mut input = input;
|
||||
input.element_mut().adjust(|color| {
|
||||
if color.a() == 0. {
|
||||
return color.with_alpha(1.);
|
||||
}
|
||||
@@ -149,10 +162,14 @@ fn brightness_contrast_classic<T: Adjust<Color> + Clone + Send + Sync + no_std_t
|
||||
GradientStops,
|
||||
)]
|
||||
#[gpu_image]
|
||||
mut input: T,
|
||||
brightness: SignedPercentageF32,
|
||||
contrast: SignedPercentageF32,
|
||||
) -> T {
|
||||
input: Item<T>,
|
||||
brightness: Item<SignedPercentageF32>,
|
||||
contrast: Item<SignedPercentageF32>,
|
||||
) -> Item<T> {
|
||||
let mut input = input;
|
||||
let brightness = brightness.into_element();
|
||||
let contrast = contrast.into_element();
|
||||
|
||||
let brightness = brightness / 255.;
|
||||
|
||||
let contrast = contrast / 100.;
|
||||
@@ -160,7 +177,7 @@ fn brightness_contrast_classic<T: Adjust<Color> + Clone + Send + Sync + no_std_t
|
||||
|
||||
let offset = brightness * contrast + brightness - contrast / 2.;
|
||||
|
||||
input.adjust(|color| color.map_gamma_rgb(|c| (c + c * contrast + offset).clamp(0., 1.)));
|
||||
input.element_mut().adjust(|color| color.map_gamma_rgb(|c| (c + c * contrast + offset).clamp(0., 1.)));
|
||||
|
||||
input
|
||||
}
|
||||
@@ -180,15 +197,20 @@ fn brightness_contrast<T: Adjust<Color> + Clone + Send + Sync + no_std_types::co
|
||||
GradientStops,
|
||||
)]
|
||||
#[gpu_image]
|
||||
mut input: T,
|
||||
brightness: SignedPercentageF32,
|
||||
contrast: SignedPercentageF32,
|
||||
use_classic: bool,
|
||||
) -> T {
|
||||
input: Item<T>,
|
||||
brightness: Item<SignedPercentageF32>,
|
||||
contrast: Item<SignedPercentageF32>,
|
||||
use_classic: Item<bool>,
|
||||
) -> Item<T> {
|
||||
let use_classic = use_classic.into_element();
|
||||
if use_classic {
|
||||
return brightness_contrast_classic(_ctx, input, brightness, contrast);
|
||||
}
|
||||
|
||||
let mut input = input;
|
||||
let brightness = brightness.into_element();
|
||||
let contrast = contrast.into_element();
|
||||
|
||||
const WINDOW_SIZE: usize = 1024;
|
||||
|
||||
// Brightness LUT
|
||||
@@ -239,7 +261,7 @@ fn brightness_contrast<T: Adjust<Color> + Clone + Send + Sync + no_std_types::co
|
||||
});
|
||||
let lut_max = (combined_lut.len() - 1) as f32;
|
||||
|
||||
input.adjust(|color| color.map_gamma_rgb(|c| combined_lut[(c * lut_max).round() as usize]));
|
||||
input.element_mut().adjust(|color| color.map_gamma_rgb(|c| combined_lut[(c * lut_max).round() as usize]));
|
||||
|
||||
input
|
||||
}
|
||||
@@ -261,14 +283,21 @@ fn levels<T: Adjust<Color> + Clone + Send + Sync + no_std_types::context::CacheH
|
||||
GradientStops,
|
||||
)]
|
||||
#[gpu_image]
|
||||
mut image: T,
|
||||
#[default(0.)] shadows: PercentageF32,
|
||||
#[default(50.)] midtones: PercentageF32,
|
||||
#[default(100.)] highlights: PercentageF32,
|
||||
#[default(0.)] output_minimums: PercentageF32,
|
||||
#[default(100.)] output_maximums: PercentageF32,
|
||||
) -> T {
|
||||
image.adjust(|color| {
|
||||
image: Item<T>,
|
||||
#[default(0.)] shadows: Item<PercentageF32>,
|
||||
#[default(50.)] midtones: Item<PercentageF32>,
|
||||
#[default(100.)] highlights: Item<PercentageF32>,
|
||||
#[default(0.)] output_minimums: Item<PercentageF32>,
|
||||
#[default(100.)] output_maximums: Item<PercentageF32>,
|
||||
) -> Item<T> {
|
||||
let mut image = image;
|
||||
let shadows = shadows.into_element();
|
||||
let midtones = midtones.into_element();
|
||||
let highlights = highlights.into_element();
|
||||
let output_minimums = output_minimums.into_element();
|
||||
let output_maximums = output_maximums.into_element();
|
||||
|
||||
image.element_mut().adjust(|color| {
|
||||
// Levels math operates in gamma space
|
||||
let [mut r, mut g, mut b, a] = color.to_gamma_srgb_channels();
|
||||
|
||||
@@ -340,34 +369,43 @@ fn black_and_white<T: Adjust<Color> + Clone + Send + Sync + no_std_types::contex
|
||||
GradientStops,
|
||||
)]
|
||||
#[gpu_image]
|
||||
mut image: T,
|
||||
#[default(Color::BLACK)] tint: Color,
|
||||
image: Item<T>,
|
||||
#[default(Color::BLACK)] tint: Item<Color>,
|
||||
#[default(40.)]
|
||||
#[range]
|
||||
#[soft(-200..300)]
|
||||
reds: PercentageF32,
|
||||
reds: Item<PercentageF32>,
|
||||
#[default(60.)]
|
||||
#[range]
|
||||
#[soft(-200..300)]
|
||||
yellows: PercentageF32,
|
||||
yellows: Item<PercentageF32>,
|
||||
#[default(40.)]
|
||||
#[range]
|
||||
#[soft(-200..300)]
|
||||
greens: PercentageF32,
|
||||
greens: Item<PercentageF32>,
|
||||
#[default(60.)]
|
||||
#[range]
|
||||
#[soft(-200..300)]
|
||||
cyans: PercentageF32,
|
||||
cyans: Item<PercentageF32>,
|
||||
#[default(20.)]
|
||||
#[range]
|
||||
#[soft(-200..300)]
|
||||
blues: PercentageF32,
|
||||
blues: Item<PercentageF32>,
|
||||
#[default(80.)]
|
||||
#[range]
|
||||
#[soft(-200..300)]
|
||||
magentas: PercentageF32,
|
||||
) -> T {
|
||||
image.adjust(|color| {
|
||||
magentas: Item<PercentageF32>,
|
||||
) -> Item<T> {
|
||||
let mut image = image;
|
||||
let tint = tint.into_element();
|
||||
let reds = reds.into_element();
|
||||
let yellows = yellows.into_element();
|
||||
let greens = greens.into_element();
|
||||
let cyans = cyans.into_element();
|
||||
let blues = blues.into_element();
|
||||
let magentas = magentas.into_element();
|
||||
|
||||
image.element_mut().adjust(|color| {
|
||||
// Black & White channel weights are tuned for gamma-space values
|
||||
let [r, g, b, alpha_part] = color.to_gamma_srgb_channels();
|
||||
|
||||
@@ -423,12 +461,17 @@ fn hue_saturation<T: Adjust<Color> + Clone + Send + Sync + no_std_types::context
|
||||
GradientStops,
|
||||
)]
|
||||
#[gpu_image]
|
||||
mut input: T,
|
||||
hue_shift: AngleF32,
|
||||
saturation_shift: SignedPercentageF32,
|
||||
lightness_shift: SignedPercentageF32,
|
||||
) -> T {
|
||||
input.adjust(|color| {
|
||||
input: Item<T>,
|
||||
hue_shift: Item<AngleF32>,
|
||||
saturation_shift: Item<SignedPercentageF32>,
|
||||
lightness_shift: Item<SignedPercentageF32>,
|
||||
) -> Item<T> {
|
||||
let mut input = input;
|
||||
let hue_shift = hue_shift.into_element();
|
||||
let saturation_shift = saturation_shift.into_element();
|
||||
let lightness_shift = lightness_shift.into_element();
|
||||
|
||||
input.element_mut().adjust(|color| {
|
||||
// HSL operates on gamma-space channels
|
||||
let [hue, saturation, lightness, alpha] = color.to_hsla();
|
||||
|
||||
@@ -455,9 +498,10 @@ fn invert<T: Adjust<Color> + Clone + Send + Sync + no_std_types::context::CacheH
|
||||
GradientStops,
|
||||
)]
|
||||
#[gpu_image]
|
||||
mut input: T,
|
||||
) -> T {
|
||||
input.adjust(|color| {
|
||||
input: Item<T>,
|
||||
) -> Item<T> {
|
||||
let mut input = input;
|
||||
input.element_mut().adjust(|color| {
|
||||
// Invert in gamma space relative to alpha
|
||||
let [r, g, b, a] = color.to_gamma_srgb_channels();
|
||||
Color::from_gamma_srgb_channels(a - r, a - g, a - b, a)
|
||||
@@ -476,12 +520,17 @@ fn threshold<T: Adjust<Color> + Clone + Send + Sync + no_std_types::context::Cac
|
||||
GradientStops,
|
||||
)]
|
||||
#[gpu_image]
|
||||
mut image: T,
|
||||
#[default(50.)] min_luminance: PercentageF32,
|
||||
#[default(100.)] max_luminance: PercentageF32,
|
||||
luminance_calc: LuminanceCalculation,
|
||||
) -> T {
|
||||
image.adjust(|color| {
|
||||
image: Item<T>,
|
||||
#[default(50.)] min_luminance: Item<PercentageF32>,
|
||||
#[default(100.)] max_luminance: Item<PercentageF32>,
|
||||
luminance_calc: Item<LuminanceCalculation>,
|
||||
) -> Item<T> {
|
||||
let mut image = image;
|
||||
let min_luminance = min_luminance.into_element();
|
||||
let max_luminance = max_luminance.into_element();
|
||||
let luminance_calc = luminance_calc.into_element();
|
||||
|
||||
image.element_mut().adjust(|color| {
|
||||
let min_luminance = srgb_to_linear(min_luminance / 100.);
|
||||
let max_luminance = srgb_to_linear(max_luminance / 100.);
|
||||
|
||||
@@ -522,10 +571,13 @@ fn vibrance<T: Adjust<Color> + Clone + Send + Sync + no_std_types::context::Cach
|
||||
GradientStops,
|
||||
)]
|
||||
#[gpu_image]
|
||||
mut image: T,
|
||||
vibrance: SignedPercentageF32,
|
||||
) -> T {
|
||||
image.adjust(|color| {
|
||||
image: Item<T>,
|
||||
vibrance: Item<SignedPercentageF32>,
|
||||
) -> Item<T> {
|
||||
let mut image = image;
|
||||
let vibrance = vibrance.into_element();
|
||||
|
||||
image.element_mut().adjust(|color| {
|
||||
let r_raw = color.r();
|
||||
let g_raw = color.g();
|
||||
let b_raw = color.b();
|
||||
@@ -724,66 +776,73 @@ fn channel_mixer<T: Adjust<Color> + Clone + Send + Sync + no_std_types::context:
|
||||
GradientStops,
|
||||
)]
|
||||
#[gpu_image]
|
||||
mut image: T,
|
||||
image: Item<T>,
|
||||
|
||||
monochrome: bool,
|
||||
monochrome: Item<bool>,
|
||||
|
||||
#[default(40.)]
|
||||
#[name("Red")]
|
||||
monochrome_r: f32,
|
||||
monochrome_r: Item<f32>,
|
||||
#[default(40.)]
|
||||
#[name("Green")]
|
||||
monochrome_g: f32,
|
||||
monochrome_g: Item<f32>,
|
||||
#[default(20.)]
|
||||
#[name("Blue")]
|
||||
monochrome_b: f32,
|
||||
monochrome_b: Item<f32>,
|
||||
#[default(0.)]
|
||||
#[name("Constant")]
|
||||
monochrome_c: f32,
|
||||
monochrome_c: Item<f32>,
|
||||
|
||||
#[default(100.)]
|
||||
#[name("(Red) Red")]
|
||||
red_r: f32,
|
||||
red_r: Item<f32>,
|
||||
#[default(0.)]
|
||||
#[name("(Red) Green")]
|
||||
red_g: f32,
|
||||
red_g: Item<f32>,
|
||||
#[default(0.)]
|
||||
#[name("(Red) Blue")]
|
||||
red_b: f32,
|
||||
red_b: Item<f32>,
|
||||
#[default(0.)]
|
||||
#[name("(Red) Constant")]
|
||||
red_c: f32,
|
||||
red_c: Item<f32>,
|
||||
|
||||
#[default(0.)]
|
||||
#[name("(Green) Red")]
|
||||
green_r: f32,
|
||||
green_r: Item<f32>,
|
||||
#[default(100.)]
|
||||
#[name("(Green) Green")]
|
||||
green_g: f32,
|
||||
green_g: Item<f32>,
|
||||
#[default(0.)]
|
||||
#[name("(Green) Blue")]
|
||||
green_b: f32,
|
||||
green_b: Item<f32>,
|
||||
#[default(0.)]
|
||||
#[name("(Green) Constant")]
|
||||
green_c: f32,
|
||||
green_c: Item<f32>,
|
||||
|
||||
#[default(0.)]
|
||||
#[name("(Blue) Red")]
|
||||
blue_r: f32,
|
||||
blue_r: Item<f32>,
|
||||
#[default(0.)]
|
||||
#[name("(Blue) Green")]
|
||||
blue_g: f32,
|
||||
blue_g: Item<f32>,
|
||||
#[default(100.)]
|
||||
#[name("(Blue) Blue")]
|
||||
blue_b: f32,
|
||||
blue_b: Item<f32>,
|
||||
#[default(0.)]
|
||||
#[name("(Blue) Constant")]
|
||||
blue_c: f32,
|
||||
blue_c: Item<f32>,
|
||||
|
||||
// Display-only properties (not used within the node)
|
||||
_output_channel: RedGreenBlue,
|
||||
) -> T {
|
||||
image.adjust(|color| {
|
||||
_output_channel: Item<RedGreenBlue>,
|
||||
) -> Item<T> {
|
||||
let mut image = image;
|
||||
let monochrome = monochrome.into_element();
|
||||
let (monochrome_r, monochrome_g, monochrome_b, monochrome_c) = (monochrome_r.into_element(), monochrome_g.into_element(), monochrome_b.into_element(), monochrome_c.into_element());
|
||||
let (red_r, red_g, red_b, red_c) = (red_r.into_element(), red_g.into_element(), red_b.into_element(), red_c.into_element());
|
||||
let (green_r, green_g, green_b, green_c) = (green_r.into_element(), green_g.into_element(), green_b.into_element(), green_c.into_element());
|
||||
let (blue_r, blue_g, blue_b, blue_c) = (blue_r.into_element(), blue_g.into_element(), blue_b.into_element(), blue_c.into_element());
|
||||
|
||||
image.element_mut().adjust(|color| {
|
||||
let [r, g, b, a] = color.to_gamma_srgb_channels();
|
||||
|
||||
let (out_r, out_g, out_b) = if monochrome {
|
||||
@@ -856,58 +915,70 @@ fn selective_color<T: Adjust<Color> + Clone + Send + Sync + no_std_types::contex
|
||||
GradientStops,
|
||||
)]
|
||||
#[gpu_image]
|
||||
mut image: T,
|
||||
image: Item<T>,
|
||||
|
||||
mode: RelativeAbsolute,
|
||||
mode: Item<RelativeAbsolute>,
|
||||
|
||||
#[name("(Reds) Cyan")] r_c: f32,
|
||||
#[name("(Reds) Magenta")] r_m: f32,
|
||||
#[name("(Reds) Yellow")] r_y: f32,
|
||||
#[name("(Reds) Black")] r_k: f32,
|
||||
#[name("(Reds) Cyan")] r_c: Item<f32>,
|
||||
#[name("(Reds) Magenta")] r_m: Item<f32>,
|
||||
#[name("(Reds) Yellow")] r_y: Item<f32>,
|
||||
#[name("(Reds) Black")] r_k: Item<f32>,
|
||||
|
||||
#[name("(Yellows) Cyan")] y_c: f32,
|
||||
#[name("(Yellows) Magenta")] y_m: f32,
|
||||
#[name("(Yellows) Yellow")] y_y: f32,
|
||||
#[name("(Yellows) Black")] y_k: f32,
|
||||
#[name("(Yellows) Cyan")] y_c: Item<f32>,
|
||||
#[name("(Yellows) Magenta")] y_m: Item<f32>,
|
||||
#[name("(Yellows) Yellow")] y_y: Item<f32>,
|
||||
#[name("(Yellows) Black")] y_k: Item<f32>,
|
||||
|
||||
#[name("(Greens) Cyan")] g_c: f32,
|
||||
#[name("(Greens) Magenta")] g_m: f32,
|
||||
#[name("(Greens) Yellow")] g_y: f32,
|
||||
#[name("(Greens) Black")] g_k: f32,
|
||||
#[name("(Greens) Cyan")] g_c: Item<f32>,
|
||||
#[name("(Greens) Magenta")] g_m: Item<f32>,
|
||||
#[name("(Greens) Yellow")] g_y: Item<f32>,
|
||||
#[name("(Greens) Black")] g_k: Item<f32>,
|
||||
|
||||
#[name("(Cyans) Cyan")] c_c: f32,
|
||||
#[name("(Cyans) Magenta")] c_m: f32,
|
||||
#[name("(Cyans) Yellow")] c_y: f32,
|
||||
#[name("(Cyans) Black")] c_k: f32,
|
||||
#[name("(Cyans) Cyan")] c_c: Item<f32>,
|
||||
#[name("(Cyans) Magenta")] c_m: Item<f32>,
|
||||
#[name("(Cyans) Yellow")] c_y: Item<f32>,
|
||||
#[name("(Cyans) Black")] c_k: Item<f32>,
|
||||
|
||||
#[name("(Blues) Cyan")] b_c: f32,
|
||||
#[name("(Blues) Magenta")] b_m: f32,
|
||||
#[name("(Blues) Yellow")] b_y: f32,
|
||||
#[name("(Blues) Black")] b_k: f32,
|
||||
#[name("(Blues) Cyan")] b_c: Item<f32>,
|
||||
#[name("(Blues) Magenta")] b_m: Item<f32>,
|
||||
#[name("(Blues) Yellow")] b_y: Item<f32>,
|
||||
#[name("(Blues) Black")] b_k: Item<f32>,
|
||||
|
||||
#[name("(Magentas) Cyan")] m_c: f32,
|
||||
#[name("(Magentas) Magenta")] m_m: f32,
|
||||
#[name("(Magentas) Yellow")] m_y: f32,
|
||||
#[name("(Magentas) Black")] m_k: f32,
|
||||
#[name("(Magentas) Cyan")] m_c: Item<f32>,
|
||||
#[name("(Magentas) Magenta")] m_m: Item<f32>,
|
||||
#[name("(Magentas) Yellow")] m_y: Item<f32>,
|
||||
#[name("(Magentas) Black")] m_k: Item<f32>,
|
||||
|
||||
#[name("(Whites) Cyan")] w_c: f32,
|
||||
#[name("(Whites) Magenta")] w_m: f32,
|
||||
#[name("(Whites) Yellow")] w_y: f32,
|
||||
#[name("(Whites) Black")] w_k: f32,
|
||||
#[name("(Whites) Cyan")] w_c: Item<f32>,
|
||||
#[name("(Whites) Magenta")] w_m: Item<f32>,
|
||||
#[name("(Whites) Yellow")] w_y: Item<f32>,
|
||||
#[name("(Whites) Black")] w_k: Item<f32>,
|
||||
|
||||
#[name("(Neutrals) Cyan")] n_c: f32,
|
||||
#[name("(Neutrals) Magenta")] n_m: f32,
|
||||
#[name("(Neutrals) Yellow")] n_y: f32,
|
||||
#[name("(Neutrals) Black")] n_k: f32,
|
||||
#[name("(Neutrals) Cyan")] n_c: Item<f32>,
|
||||
#[name("(Neutrals) Magenta")] n_m: Item<f32>,
|
||||
#[name("(Neutrals) Yellow")] n_y: Item<f32>,
|
||||
#[name("(Neutrals) Black")] n_k: Item<f32>,
|
||||
|
||||
#[name("(Blacks) Cyan")] k_c: f32,
|
||||
#[name("(Blacks) Magenta")] k_m: f32,
|
||||
#[name("(Blacks) Yellow")] k_y: f32,
|
||||
#[name("(Blacks) Black")] k_k: f32,
|
||||
#[name("(Blacks) Cyan")] k_c: Item<f32>,
|
||||
#[name("(Blacks) Magenta")] k_m: Item<f32>,
|
||||
#[name("(Blacks) Yellow")] k_y: Item<f32>,
|
||||
#[name("(Blacks) Black")] k_k: Item<f32>,
|
||||
|
||||
_colors: SelectiveColorChoice,
|
||||
) -> T {
|
||||
image.adjust(|color| {
|
||||
_colors: Item<SelectiveColorChoice>,
|
||||
) -> Item<T> {
|
||||
let mut image = image;
|
||||
let mode = mode.into_element();
|
||||
let (r_c, r_m, r_y, r_k) = (r_c.into_element(), r_m.into_element(), r_y.into_element(), r_k.into_element());
|
||||
let (y_c, y_m, y_y, y_k) = (y_c.into_element(), y_m.into_element(), y_y.into_element(), y_k.into_element());
|
||||
let (g_c, g_m, g_y, g_k) = (g_c.into_element(), g_m.into_element(), g_y.into_element(), g_k.into_element());
|
||||
let (c_c, c_m, c_y, c_k) = (c_c.into_element(), c_m.into_element(), c_y.into_element(), c_k.into_element());
|
||||
let (b_c, b_m, b_y, b_k) = (b_c.into_element(), b_m.into_element(), b_y.into_element(), b_k.into_element());
|
||||
let (m_c, m_m, m_y, m_k) = (m_c.into_element(), m_m.into_element(), m_y.into_element(), m_k.into_element());
|
||||
let (w_c, w_m, w_y, w_k) = (w_c.into_element(), w_m.into_element(), w_y.into_element(), w_k.into_element());
|
||||
let (n_c, n_m, n_y, n_k) = (n_c.into_element(), n_m.into_element(), n_y.into_element(), n_k.into_element());
|
||||
let (k_c, k_m, k_y, k_k) = (k_c.into_element(), k_m.into_element(), k_y.into_element(), k_k.into_element());
|
||||
|
||||
image.element_mut().adjust(|color| {
|
||||
let [r, g, b, a] = color.to_gamma_srgb_channels();
|
||||
|
||||
let min = |a: f32, b: f32, c: f32| a.min(b).min(c);
|
||||
@@ -1000,13 +1071,15 @@ fn posterize<T: Adjust<Color> + Clone + Send + Sync + no_std_types::context::Cac
|
||||
GradientStops,
|
||||
)]
|
||||
#[gpu_image]
|
||||
mut input: T,
|
||||
input: Item<T>,
|
||||
#[default(4)]
|
||||
#[hard(2..)]
|
||||
levels: u32,
|
||||
) -> T {
|
||||
let levels = levels as f32;
|
||||
input.adjust(|color| {
|
||||
levels: Item<u32>,
|
||||
) -> Item<T> {
|
||||
let mut input = input;
|
||||
let levels = levels.into_element() as f32;
|
||||
|
||||
input.element_mut().adjust(|color| {
|
||||
let number_of_areas = levels.recip();
|
||||
let size_of_areas = (levels - 1.).recip();
|
||||
color.map_gamma_rgb(|c| (c / number_of_areas).floor() * size_of_areas)
|
||||
@@ -1029,16 +1102,21 @@ fn exposure<T: Adjust<Color> + Clone + Send + Sync + no_std_types::context::Cach
|
||||
GradientStops,
|
||||
)]
|
||||
#[gpu_image]
|
||||
mut input: T,
|
||||
exposure: f32,
|
||||
offset: f32,
|
||||
input: Item<T>,
|
||||
exposure: Item<f32>,
|
||||
offset: Item<f32>,
|
||||
#[default(1.)]
|
||||
#[range]
|
||||
#[hard(0.0001..)]
|
||||
#[soft(0.01..10)]
|
||||
gamma_correction: f32,
|
||||
) -> T {
|
||||
input.adjust(|color| {
|
||||
gamma_correction: Item<f32>,
|
||||
) -> Item<T> {
|
||||
let mut input = input;
|
||||
let exposure = exposure.into_element();
|
||||
let offset = offset.into_element();
|
||||
let gamma_correction = gamma_correction.into_element();
|
||||
|
||||
input.element_mut().adjust(|color| {
|
||||
let adjusted = color
|
||||
// Exposure
|
||||
.map_rgb(|c: f32| c * 2_f32.powf(exposure))
|
||||
|
||||
@@ -6,7 +6,7 @@ use no_std_types::registry::types::PercentageF32;
|
||||
#[cfg(feature = "std")]
|
||||
use raster_types::{CPU, Raster};
|
||||
#[cfg(feature = "std")]
|
||||
use vector_types::{GradientStop, GradientStops};
|
||||
use vector_types::{Gradient, GradientStop};
|
||||
|
||||
pub trait Blend<P: Pixel> {
|
||||
fn blend(&self, under: &Self, blend_fn: impl Fn(P, P) -> P) -> Self;
|
||||
@@ -36,73 +36,28 @@ mod blend_std {
|
||||
}
|
||||
}
|
||||
|
||||
impl Blend<Color> for GradientStops {
|
||||
impl Blend<Color> for Gradient {
|
||||
// TODO: This joining is unfaithful in several ways: it samples only at stop positions so midpoint curves flatten away;
|
||||
// TODO: it evaluates both sources with default whole-ramp attributes rather than their own (which this element-level impl cannot read);
|
||||
// TODO: and the output keeps over's attributes despite being sampled with defaults
|
||||
fn blend(&self, under: &Self, blend_fn: impl Fn(Color, Color) -> Color) -> Self {
|
||||
let mut combined_stops = self.position.iter().chain(under.position.iter()).copied().collect::<Vec<_>>();
|
||||
combined_stops.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
|
||||
let mut combined_stops = self.positions(false).into_iter().chain(under.positions(false)).collect::<Vec<_>>();
|
||||
combined_stops.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
|
||||
combined_stops.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
|
||||
let over_evaluator = self.evaluator(Default::default());
|
||||
let under_evaluator = under.evaluator(Default::default());
|
||||
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);
|
||||
let color = blend_fn(over_evaluator.evaluate(position), under_evaluator.evaluate(position));
|
||||
GradientStop { position, midpoint: 0.5, color }
|
||||
});
|
||||
GradientStops::new(stops)
|
||||
|
||||
// Positions stay explicit because eliding them needs the cyclic flag this impl can't read, and a wrong guess would relocate the stops
|
||||
Gradient::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.apply_opacity(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"),
|
||||
}
|
||||
}
|
||||
pub use no_std_types::blending::{apply_blend_mode, blend_colors};
|
||||
|
||||
#[cfg(feature = "std")]
|
||||
#[node_macro::node(category("Raster"), cfg(feature = "std"))]
|
||||
@@ -111,7 +66,7 @@ fn mix<T: Blend<Color> + Clone + Send + Sync + core_types::CacheHash + 'static>(
|
||||
#[implementations(
|
||||
Raster<CPU>,
|
||||
Color,
|
||||
GradientStops,
|
||||
Gradient,
|
||||
)]
|
||||
#[gpu_image]
|
||||
over: T,
|
||||
@@ -119,7 +74,7 @@ fn mix<T: Blend<Color> + Clone + Send + Sync + core_types::CacheHash + 'static>(
|
||||
#[implementations(
|
||||
Raster<CPU>,
|
||||
Color,
|
||||
GradientStops,
|
||||
Gradient,
|
||||
)]
|
||||
#[gpu_image]
|
||||
under: T,
|
||||
@@ -135,7 +90,7 @@ fn color_overlay<T: Adjust<Color> + Clone + Send + Sync + no_std_types::context:
|
||||
#[implementations(
|
||||
Raster<CPU>,
|
||||
Color,
|
||||
GradientStops,
|
||||
Gradient,
|
||||
)]
|
||||
#[gpu_image]
|
||||
mut image: T,
|
||||
|
||||
@@ -94,9 +94,9 @@ fn blur(
|
||||
#[range]
|
||||
#[hard(0..)]
|
||||
#[soft(..100)]
|
||||
radius: PixelLength,
|
||||
radius: Item<PixelLength>,
|
||||
/// Use a lower-quality box kernel instead of a circular Gaussian kernel. This is faster but produces boxy artifacts.
|
||||
box_blur: bool,
|
||||
box_blur: Item<bool>,
|
||||
/// Opt to incorrectly apply the filter with color calculations in gamma space for compatibility with the results from other software.
|
||||
gamma: bool,
|
||||
) -> Raster<CPU> {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
//! Not immediately shader compatible due to needing [`GradientStops`] as a param, which needs [`Vec`]
|
||||
//! Not immediately shader compatible due to needing [`Gradient`] as a param, which needs [`Vec`]
|
||||
|
||||
use crate::adjust::Adjust;
|
||||
use core_types::{Color, Ctx};
|
||||
use raster_types::{CPU, Raster};
|
||||
use vector_types::GradientStops;
|
||||
use vector_types::markers::{
|
||||
GradientCyclic as GradientCyclicAttr, GradientHueDirection as GradientHueDirectionAttr, GradientInterpolation as GradientInterpolationAttr, GradientSpace as GradientSpaceAttr,
|
||||
GradientSpread as GradientSpreadAttr,
|
||||
};
|
||||
use vector_types::{Gradient, GradientSettings};
|
||||
|
||||
// Aims for interoperable compatibility with:
|
||||
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27grdm%27%20%3D%20Gradient%20Map
|
||||
@@ -14,21 +18,30 @@ fn gradient_map<T: Adjust<Color> + Clone + Send + Sync + core_types::CacheHash +
|
||||
#[implementations(
|
||||
Raster<CPU>,
|
||||
Color,
|
||||
GradientStops,
|
||||
Gradient,
|
||||
)]
|
||||
mut image: T,
|
||||
gradient: IList<GradientStops>,
|
||||
#[default(Color::BLACK, Color::WHITE)] gradient: IList<Gradient>,
|
||||
reverse: bool,
|
||||
) -> T {
|
||||
if gradient.is_empty() {
|
||||
return image;
|
||||
}
|
||||
let gradient = gradient.element_ref(0);
|
||||
// Master reads the whole-ramp settings off the item; ours ride the gradient's own lane.
|
||||
let lane = gradient.lane(0);
|
||||
let settings = GradientSettings {
|
||||
spread: lane.attr::<GradientSpreadAttr>(),
|
||||
cyclic: lane.attr::<GradientCyclicAttr>(),
|
||||
space: lane.attr::<GradientSpaceAttr>(),
|
||||
hue_direction: lane.attr::<GradientHueDirectionAttr>(),
|
||||
interpolation: lane.attr::<GradientInterpolationAttr>(),
|
||||
};
|
||||
let evaluator = gradient.element_ref(0).evaluator(settings);
|
||||
|
||||
image.adjust(|color| {
|
||||
let intensity = color.luminance_rec_709();
|
||||
let intensity = if reverse { 1. - intensity } else { intensity };
|
||||
gradient.evaluate(intensity as f64)
|
||||
evaluator.evaluate(intensity as f64)
|
||||
});
|
||||
|
||||
image
|
||||
|
||||
@@ -369,32 +369,32 @@ pub fn image(_: impl Ctx, resource: Resource) -> Raster<CPU> {
|
||||
pub fn noise_pattern(
|
||||
ctx: impl ExtractFootprint + Ctx,
|
||||
_primary: (),
|
||||
#[default(true)] clip: bool,
|
||||
seed: u32,
|
||||
#[default(true)] clip: Item<bool>,
|
||||
seed: Item<u32>,
|
||||
#[widget(ParsedWidgetOverride::Custom = "noise_properties_scale")]
|
||||
#[default(10.)]
|
||||
scale: f64,
|
||||
#[widget(ParsedWidgetOverride::Custom = "noise_properties_noise_type")] noise_type: NoiseType,
|
||||
#[widget(ParsedWidgetOverride::Custom = "noise_properties_domain_warp_type")] domain_warp_type: DomainWarpType,
|
||||
scale: Item<f64>,
|
||||
#[widget(ParsedWidgetOverride::Custom = "noise_properties_noise_type")] noise_type: Item<NoiseType>,
|
||||
#[widget(ParsedWidgetOverride::Custom = "noise_properties_domain_warp_type")] domain_warp_type: Item<DomainWarpType>,
|
||||
#[widget(ParsedWidgetOverride::Custom = "noise_properties_domain_warp_amplitude")]
|
||||
#[default(100.)]
|
||||
domain_warp_amplitude: f64,
|
||||
#[widget(ParsedWidgetOverride::Custom = "noise_properties_fractal_type")] fractal_type: FractalType,
|
||||
domain_warp_amplitude: Item<f64>,
|
||||
#[widget(ParsedWidgetOverride::Custom = "noise_properties_fractal_type")] fractal_type: Item<FractalType>,
|
||||
#[widget(ParsedWidgetOverride::Custom = "noise_properties_fractal_octaves")]
|
||||
#[default(3)]
|
||||
fractal_octaves: u32,
|
||||
fractal_octaves: Item<u32>,
|
||||
#[widget(ParsedWidgetOverride::Custom = "noise_properties_fractal_lacunarity")]
|
||||
#[default(2.)]
|
||||
fractal_lacunarity: f64,
|
||||
fractal_lacunarity: Item<f64>,
|
||||
#[widget(ParsedWidgetOverride::Custom = "noise_properties_fractal_gain")]
|
||||
#[default(0.5)]
|
||||
fractal_gain: f64,
|
||||
#[widget(ParsedWidgetOverride::Custom = "noise_properties_fractal_weighted_strength")] fractal_weighted_strength: f64,
|
||||
fractal_gain: Item<f64>,
|
||||
#[widget(ParsedWidgetOverride::Custom = "noise_properties_fractal_weighted_strength")] fractal_weighted_strength: Item<f64>,
|
||||
#[widget(ParsedWidgetOverride::Custom = "noise_properties_ping_pong_strength")]
|
||||
#[default(2.)]
|
||||
fractal_ping_pong_strength: f64,
|
||||
#[widget(ParsedWidgetOverride::Custom = "noise_properties_cellular_distance_function")] cellular_distance_function: CellularDistanceFunction,
|
||||
#[widget(ParsedWidgetOverride::Custom = "noise_properties_cellular_return_type")] cellular_return_type: CellularReturnType,
|
||||
fractal_ping_pong_strength: Item<f64>,
|
||||
#[widget(ParsedWidgetOverride::Custom = "noise_properties_cellular_distance_function")] cellular_distance_function: Item<CellularDistanceFunction>,
|
||||
#[widget(ParsedWidgetOverride::Custom = "noise_properties_cellular_return_type")] cellular_return_type: Item<CellularReturnType>,
|
||||
#[widget(ParsedWidgetOverride::Custom = "noise_properties_cellular_jitter")]
|
||||
#[default(1.)]
|
||||
cellular_jitter: f64,
|
||||
|
||||
@@ -51,8 +51,8 @@ pub fn repeat_array<T>(
|
||||
content: impl Node<Context<'_>, Output = (T, Attr<TransformAttr>)>,
|
||||
#[default(100., 100.)]
|
||||
// TODO: When using a custom Properties panel layout in document_node_definitions.rs and this default is set, the widget weirdly doesn't show up in the Properties panel. Investigation is needed.
|
||||
direction: PixelSize,
|
||||
angle: Angle,
|
||||
direction: Item<PixelSize>,
|
||||
angle: Item<Angle>,
|
||||
#[default(5)]
|
||||
#[hard(1..)]
|
||||
count: u32,
|
||||
@@ -95,7 +95,7 @@ fn repeat_radial<T>(
|
||||
start_angle: Angle,
|
||||
#[unit(" px")]
|
||||
#[default(5)]
|
||||
radius: f64,
|
||||
radius: Item<f64>,
|
||||
#[default(5)]
|
||||
#[hard(1..)]
|
||||
count: u32,
|
||||
@@ -185,7 +185,7 @@ mod test {
|
||||
use core_types::node::Node;
|
||||
use core_types::record::{FieldWrite, FrameClaim, Layout, RecordSource, Served, capture, element_write};
|
||||
use core_types::value::ValueSource;
|
||||
use vector_types::subpath::Subpath;
|
||||
use vector_types::vector::algorithms::shapes::polyline_bezpath;
|
||||
|
||||
struct TransformSource {
|
||||
layout: Layout,
|
||||
@@ -399,8 +399,8 @@ mod test {
|
||||
let points = VectorRows {
|
||||
layout: vector_rows_layout(),
|
||||
rows: vec![
|
||||
(Vector::from_subpath(Subpath::from_anchors(row0.clone(), false)), row0_transform),
|
||||
(Vector::from_subpath(Subpath::from_anchors(row1.clone(), false)), DAffine2::IDENTITY),
|
||||
(Vector::from_bezpath(polyline_bezpath(row0.clone(), false)), row0_transform),
|
||||
(Vector::from_bezpath(polyline_bezpath(row1.clone(), false)), DAffine2::IDENTITY),
|
||||
],
|
||||
};
|
||||
let content_layout = transform_layout();
|
||||
@@ -442,7 +442,7 @@ mod test {
|
||||
let positions: Vec<DVec2> = vec![DVec2::new(40., 20.), DVec2::ONE, DVec2::new(-42., 9.), DVec2::new(10., 345.)];
|
||||
let points = VectorRows {
|
||||
layout: vector_rows_layout(),
|
||||
rows: vec![(Vector::from_subpath(Subpath::from_anchors(positions.clone(), false)), DAffine2::IDENTITY)],
|
||||
rows: vec![(Vector::from_bezpath(polyline_bezpath(positions.clone(), false)), DAffine2::IDENTITY)],
|
||||
};
|
||||
let content_layout = transform_layout();
|
||||
let content = PositionProbe { layout: content_layout.clone() };
|
||||
|
||||
@@ -64,7 +64,7 @@ impl Default for Font {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Eventually remove this migration document upgrade code
|
||||
// TODO: Eventually remove this 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 })
|
||||
|
||||
@@ -14,37 +14,44 @@ fn format_json(
|
||||
_: impl Ctx,
|
||||
/// The JSON string to reformat.
|
||||
#[name("JSON")]
|
||||
json: String,
|
||||
json: Item<String>,
|
||||
/// Removes optional spaces within curly brackets and after colons and commas.
|
||||
compact: bool,
|
||||
compact: Item<bool>,
|
||||
/// Break arrays and objects across multiple lines when they exceed the line break length.
|
||||
#[default(true)]
|
||||
#[name("Multi-Line")]
|
||||
multi_line: bool,
|
||||
multi_line: Item<bool>,
|
||||
/// The indentation string used for each nesting level. Escape sequences like `\t` (the tab character) are supported. Two or four spaces are also common choices.
|
||||
#[default("\\t")]
|
||||
indent: String,
|
||||
indent: Item<String>,
|
||||
/// The maximum line length before a container (array or object) is broken across lines. Set this to 0 to always break containers. (Requires *Multi-Line* to take effect.)
|
||||
///
|
||||
/// This is not a maximum line length guarantee. Deep nesting and long keys or values may exceed this length.
|
||||
#[default(120)]
|
||||
break_length: u32,
|
||||
break_length: Item<u32>,
|
||||
/// Always break a container (array or object) across lines if it holds another container, even if it would fit within the break length. (Requires *Multi-Line* to take effect.)
|
||||
#[default(true)]
|
||||
break_nested: bool,
|
||||
) -> String {
|
||||
let cleaned = strip_trailing_commas(&json);
|
||||
break_nested: Item<bool>,
|
||||
) -> Item<String> {
|
||||
let mut json = json;
|
||||
let (compact, multi_line, break_length, break_nested) = (*compact.element(), *multi_line.element(), *break_length.element(), *break_nested.element());
|
||||
let indent = indent.element().clone();
|
||||
|
||||
let cleaned = strip_trailing_commas(json.element());
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(&cleaned) else { return json };
|
||||
let indent = unescape_string(indent);
|
||||
let colon = if compact { ":" } else { ": " };
|
||||
let comma_space = if compact { "," } else { ", " };
|
||||
let line_width = break_length as usize;
|
||||
|
||||
if multi_line {
|
||||
let result = if multi_line {
|
||||
format_value(&value, 0, &indent, colon, comma_space, compact, break_nested, line_width)
|
||||
} else {
|
||||
format_inline(&value, colon, comma_space, compact)
|
||||
}
|
||||
};
|
||||
|
||||
*json.element_mut() = result;
|
||||
json
|
||||
}
|
||||
|
||||
/// Strips trailing commas before `]` and `}` to accept JSON-with-trailing-commas input.
|
||||
@@ -188,7 +195,7 @@ fn query_json(
|
||||
_: impl Ctx,
|
||||
/// The JSON string to extract a value from.
|
||||
#[name("JSON")]
|
||||
json: String,
|
||||
json: Item<String>,
|
||||
/// Determines which contained value to extract from within the JSON.
|
||||
///
|
||||
/// The path syntax is like JavaScript's accessor syntax that follows an array/object value. It also supports negative indexing to count backwards from the end. Additionally, `[]` accesses all array and object values instead of just one.
|
||||
@@ -198,19 +205,29 @@ fn query_json(
|
||||
/// Use `.size` or `["size"]` to get the `size` property of `{ "size": 10 }`. The latter form is required if the key contains spaces or special characters like `["this key with spaces!"]`.
|
||||
/// Use chained accessors like `.fonts[0].name` to query deeper.
|
||||
/// Use the `[]` accessor to query all elements, like `.fonts[].weights[]` to get every weight of every font.
|
||||
path: String,
|
||||
path: Item<String>,
|
||||
/// Strips the surrounding double quotes from string values, returning the raw text. Other types are never wrapped in quotes.
|
||||
#[default(true)]
|
||||
unquote_strings: bool,
|
||||
) -> String {
|
||||
let cleaned = strip_trailing_commas(&json);
|
||||
let Ok(value): Result<Value, _> = serde_json::from_str(&cleaned) else { return String::new() };
|
||||
let Some(segments) = parse_json_path(path.trim()) else { return String::new() };
|
||||
unquote_strings: Item<bool>,
|
||||
) -> Item<String> {
|
||||
let mut json = json;
|
||||
let path = path.element().clone();
|
||||
let unquote_strings = *unquote_strings.element();
|
||||
|
||||
let mut results = Vec::new();
|
||||
resolve_all(&value, &segments, !unquote_strings, &mut results);
|
||||
let cleaned = strip_trailing_commas(json.element());
|
||||
|
||||
results.into_iter().next().map(|(text, _ty)| text).unwrap_or_default()
|
||||
let result = match (serde_json::from_str::<Value>(&cleaned), parse_json_path(path.trim())) {
|
||||
(Ok(value), Some(segments)) => {
|
||||
let mut results = Vec::new();
|
||||
resolve_all(&value, &segments, !unquote_strings, &mut results);
|
||||
|
||||
results.into_iter().next().map(|(text, _ty)| text).unwrap_or_default()
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
*json.element_mut() = result;
|
||||
json
|
||||
}
|
||||
|
||||
/// Extracts every matched value from a JSON string using a path expression (see that parameter's description for its syntax). A list of zero or more resultant strings is produced. The `[]` path accessor is used to read more than one value.
|
||||
@@ -226,7 +243,7 @@ fn query_json_all(
|
||||
_: impl Ctx,
|
||||
/// The JSON string to extract values from.
|
||||
#[name("JSON")]
|
||||
json: String,
|
||||
json: Item<String>,
|
||||
/// Determines which contained values to extract from within the JSON.
|
||||
///
|
||||
/// The path syntax is like JavaScript's accessor syntax that follows an array/object value. It also supports negative indexing to count backwards from the end. Additionally, `[]` accesses all array and object values instead of just one.
|
||||
@@ -236,17 +253,17 @@ fn query_json_all(
|
||||
/// Use `.size` or `["size"]` to get the `size` property of `{ "size": 10 }`. The latter form is required if the key contains spaces or special characters like `["this key with spaces!"]`.
|
||||
/// Use chained accessors like `.fonts[0].name` to query deeper.
|
||||
/// Use the `[]` accessor to query all elements, like `.fonts[].weights[]` to get every weight of every font.
|
||||
path: String,
|
||||
path: Item<String>,
|
||||
/// Strips the surrounding double quotes from string values, returning the raw text. Other types are never wrapped in quotes.
|
||||
#[default(true)]
|
||||
unquote_strings: bool,
|
||||
unquote_strings: Item<bool>,
|
||||
) -> List<String> {
|
||||
let cleaned = strip_trailing_commas(&json);
|
||||
let cleaned = strip_trailing_commas(json.element());
|
||||
let Ok(value): Result<Value, _> = serde_json::from_str(&cleaned) else { return List::new() };
|
||||
let Some(segments) = parse_json_path(path.trim()) else { return List::new() };
|
||||
let Some(segments) = parse_json_path(path.element().trim()) else { return List::new() };
|
||||
|
||||
let mut results = Vec::new();
|
||||
resolve_all(&value, &segments, !unquote_strings, &mut results);
|
||||
resolve_all(&value, &segments, !*unquote_strings.element(), &mut results);
|
||||
|
||||
results.into_iter().map(|(text, ty)| Item::new_from_element(text).with_attribute(ATTR_TYPE, ty.to_string())).collect()
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use convert_case::{Boundary, Converter, pattern};
|
||||
use core_types::gpoll::Interrupt;
|
||||
use core_types::graphene_hash::CacheHash;
|
||||
use core_types::list::{Item, List};
|
||||
use core_types::math::float_noise::round_away_float_noise;
|
||||
use core_types::registry::types::{SignedInteger, TextArea};
|
||||
use core_types::{Context, Ctx, DeriveCtx, ExtractVarArgs};
|
||||
use dyn_any::DynAny;
|
||||
@@ -187,34 +188,43 @@ pub enum StringCapitalization {
|
||||
|
||||
/// 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 {
|
||||
fn string_value(_: impl Ctx, _primary: (), string: Item<TextArea>) -> Item<String> {
|
||||
string
|
||||
}
|
||||
|
||||
/// Type-asserts a value to be a string.
|
||||
#[node_macro::node(category("Debug"))]
|
||||
fn as_string(_: impl Ctx, value: String) -> String {
|
||||
#[node_macro::node(category("Type Assertion"))]
|
||||
fn as_string(_: impl Ctx, value: Item<String>) -> Item<String> {
|
||||
value
|
||||
}
|
||||
|
||||
/// Joins two strings together.
|
||||
#[node_macro::node(category("Text"))]
|
||||
fn string_concatenate(_: impl Ctx, #[implementations(String)] first: String, second: TextArea) -> String {
|
||||
first + &second
|
||||
fn string_concatenate(_: impl Ctx, #[implementations(String)] first: Item<String>, second: Item<TextArea>) -> Item<String> {
|
||||
let mut first = first;
|
||||
first.element_mut().push_str(second.element());
|
||||
first
|
||||
}
|
||||
|
||||
/// 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)
|
||||
fn string_replace(_: impl Ctx, string: Item<String>, from: Item<TextArea>, to: Item<TextArea>) -> Item<String> {
|
||||
let mut string = string;
|
||||
let result = string.element().replace(from.element().as_str(), to.element());
|
||||
|
||||
*string.element_mut() = result;
|
||||
string
|
||||
}
|
||||
|
||||
/// Extracts a substring from the input string, starting at "Start" and ending before "End".
|
||||
///
|
||||
/// Negative indices count from the end of the string. If the index of "Start" equals or exceeds "End", the result is an empty string.
|
||||
#[node_macro::node(category("Text"))]
|
||||
fn string_slice(_: impl Ctx, string: String, start: SignedInteger, end: SignedInteger) -> String {
|
||||
let total_graphemes = string.graphemes(true).count();
|
||||
fn string_slice(_: impl Ctx, string: Item<String>, start: Item<SignedInteger>, end: Item<SignedInteger>) -> Item<String> {
|
||||
let mut string = string;
|
||||
let (start, end) = (*start.element(), *end.element());
|
||||
|
||||
let total_graphemes = string.element().graphemes(true).count();
|
||||
|
||||
let start = if start < 0. {
|
||||
total_graphemes.saturating_sub(start.abs() as usize)
|
||||
@@ -227,11 +237,14 @@ fn string_slice(_: impl Ctx, string: String, start: SignedInteger, end: SignedIn
|
||||
(end as usize).min(total_graphemes)
|
||||
};
|
||||
|
||||
if start >= end {
|
||||
return String::new();
|
||||
}
|
||||
let result = if start >= end {
|
||||
String::new()
|
||||
} else {
|
||||
string.element().graphemes(true).skip(start).take(end - start).collect()
|
||||
};
|
||||
|
||||
string.graphemes(true).skip(start).take(end - start).collect()
|
||||
*string.element_mut() = result;
|
||||
string
|
||||
}
|
||||
|
||||
/// Clips the string to a maximum character length, optionally appending a suffix (like "…") when truncation occurs. Strings already within the limit are not modified.
|
||||
@@ -239,27 +252,30 @@ fn string_slice(_: impl Ctx, string: String, start: SignedInteger, end: SignedIn
|
||||
fn string_truncate(
|
||||
_: impl Ctx,
|
||||
/// The string to truncate.
|
||||
string: String,
|
||||
string: Item<String>,
|
||||
/// The maximum number of characters allowed, including the suffix if one is appended.
|
||||
#[default(80)]
|
||||
length: u32,
|
||||
length: Item<u32>,
|
||||
/// A suffix appended to indicate truncation occurred, unless empty. Its length counts towards the character budget.
|
||||
#[default("…")]
|
||||
suffix: String,
|
||||
) -> String {
|
||||
let max_length = length as usize;
|
||||
let grapheme_count = string.graphemes(true).count();
|
||||
suffix: Item<String>,
|
||||
) -> Item<String> {
|
||||
let mut string = string;
|
||||
let max_length = *length.element() as usize;
|
||||
let grapheme_count = string.element().graphemes(true).count();
|
||||
|
||||
if grapheme_count <= max_length {
|
||||
return string;
|
||||
}
|
||||
|
||||
let suffix: String = suffix.graphemes(true).take(max_length).collect();
|
||||
let suffix: String = suffix.element().graphemes(true).take(max_length).collect();
|
||||
let keep = max_length - suffix.graphemes(true).count();
|
||||
|
||||
let mut truncated: String = string.graphemes(true).take(keep).collect();
|
||||
let mut truncated: String = string.element().graphemes(true).take(keep).collect();
|
||||
truncated.push_str(&suffix);
|
||||
truncated
|
||||
|
||||
*string.element_mut() = truncated;
|
||||
string
|
||||
}
|
||||
|
||||
/// Formats a number as a string with control over decimal places, decimal separator, and thousands grouping.
|
||||
@@ -267,25 +283,32 @@ fn string_truncate(
|
||||
fn format_number(
|
||||
_: impl Ctx,
|
||||
/// The number to format as a string.
|
||||
number: f64,
|
||||
number: Item<f64>,
|
||||
/// The amount of digits after the decimal point. The value is rounded to fit. Set to 0 to show only whole numbers.
|
||||
#[default(2)]
|
||||
decimal_places: u32,
|
||||
decimal_places: Item<u32>,
|
||||
/// The character(s) used as the decimal point.
|
||||
#[default(".")]
|
||||
decimal_separator: String,
|
||||
decimal_separator: Item<String>,
|
||||
/// Always show the exact number of decimal places, even if they are trailing zeros.
|
||||
#[default(true)]
|
||||
fixed_decimals: bool,
|
||||
fixed_decimals: Item<bool>,
|
||||
/// Whether to group digits with a thousands separator.
|
||||
use_thousands_separator: bool,
|
||||
use_thousands_separator: Item<bool>,
|
||||
/// The character(s) inserted between digit groups.
|
||||
#[default(",")]
|
||||
thousands_separator: String,
|
||||
thousands_separator: Item<String>,
|
||||
/// Don't group 4-digit numbers with a thousands separator (only start grouping at 10,000 and above).
|
||||
#[name("Start at 10,000")]
|
||||
start_at_10000: bool,
|
||||
) -> String {
|
||||
start_at_10000: Item<bool>,
|
||||
) -> Item<String> {
|
||||
let (number, attributes) = number.into_parts();
|
||||
let number = round_away_float_noise(number);
|
||||
let (decimal_places, fixed_decimals, use_thousands_separator, start_at_10000) =
|
||||
(*decimal_places.element(), *fixed_decimals.element(), *use_thousands_separator.element(), *start_at_10000.element());
|
||||
let decimal_separator = decimal_separator.element().clone();
|
||||
let thousands_separator = thousands_separator.element().clone();
|
||||
|
||||
// Find the maximum meaningful decimal precision by detecting where float noise begins.
|
||||
// This works correctly whether the value originated as f32 or f64, since we find the
|
||||
// shortest decimal representation that round-trips back to the same f64 value.
|
||||
@@ -340,36 +363,38 @@ fn format_number(
|
||||
};
|
||||
|
||||
// Build the final string
|
||||
let Some(decimal_string) = decimal_string else {
|
||||
if fixed_decimals && requested_places > 0 {
|
||||
let result = match decimal_string {
|
||||
None if fixed_decimals && requested_places > 0 => {
|
||||
let zeros = "0".repeat(requested_places);
|
||||
return format!("{sign}{grouped_whole}{decimal_separator}{zeros}");
|
||||
format!("{sign}{grouped_whole}{decimal_separator}{zeros}")
|
||||
}
|
||||
None => format!("{sign}{grouped_whole}"),
|
||||
Some(decimal_string) if fixed_decimals => format!("{sign}{grouped_whole}{decimal_separator}{decimal_string}"),
|
||||
Some(decimal_string) => {
|
||||
let trimmed = decimal_string.trim_end_matches('0');
|
||||
if trimmed.is_empty() {
|
||||
format!("{sign}{grouped_whole}")
|
||||
} else {
|
||||
format!("{sign}{grouped_whole}{decimal_separator}{trimmed}")
|
||||
}
|
||||
}
|
||||
return format!("{sign}{grouped_whole}");
|
||||
};
|
||||
|
||||
if fixed_decimals {
|
||||
format!("{sign}{grouped_whole}{decimal_separator}{decimal_string}")
|
||||
} else {
|
||||
let trimmed = decimal_string.trim_end_matches('0');
|
||||
if trimmed.is_empty() {
|
||||
format!("{sign}{grouped_whole}")
|
||||
} else {
|
||||
format!("{sign}{grouped_whole}{decimal_separator}{trimmed}")
|
||||
}
|
||||
}
|
||||
Item::from_parts(result, attributes)
|
||||
}
|
||||
|
||||
/// Parses a string into a number. Falls back to the chosen value if the string is not a valid number.
|
||||
#[node_macro::node(category("Text"))]
|
||||
#[node_macro::node(category("Text"), name("String to Number"))]
|
||||
fn string_to_number(
|
||||
_: impl Ctx,
|
||||
/// The string containing a number. Surrounding whitespace is ignored, a decimal point (.) may be included, sign prefixes (+/-) are respected, and scientific notation (e.g. "1e-3") is supported.
|
||||
string: String,
|
||||
string: Item<String>,
|
||||
/// The value of the result if the string cannot be parsed as a valid number.
|
||||
fallback: f64,
|
||||
) -> f64 {
|
||||
string.trim().parse::<f64>().unwrap_or(fallback)
|
||||
fallback: Item<f64>,
|
||||
) -> Item<f64> {
|
||||
let (string, attributes) = string.into_parts();
|
||||
|
||||
Item::from_parts(string.trim().parse::<f64>().unwrap_or(*fallback.element()), attributes)
|
||||
}
|
||||
|
||||
/// Removes leading and/or trailing whitespace from a string. Common whitespace characters include spaces, tabs, and newlines.
|
||||
@@ -377,20 +402,26 @@ fn string_to_number(
|
||||
fn string_trim(
|
||||
_: impl Ctx,
|
||||
/// The string that may contain leading and trailing whitespace that should be removed.
|
||||
string: String,
|
||||
string: Item<String>,
|
||||
/// Whether the start of the string should have its whitespace removed.
|
||||
#[default(true)]
|
||||
start: bool,
|
||||
start: Item<bool>,
|
||||
/// Whether the end of the string should have its whitespace removed.
|
||||
#[default(true)]
|
||||
end: bool,
|
||||
) -> String {
|
||||
match (start, end) {
|
||||
(true, true) => string.trim().to_string(),
|
||||
(true, false) => string.trim_start().to_string(),
|
||||
(false, true) => string.trim_end().to_string(),
|
||||
(false, false) => string,
|
||||
}
|
||||
end: Item<bool>,
|
||||
) -> Item<String> {
|
||||
let mut string = string;
|
||||
let (start, end) = (*start.element(), *end.element());
|
||||
|
||||
let result = match (start, end) {
|
||||
(true, true) => string.element().trim().to_string(),
|
||||
(true, false) => string.element().trim_start().to_string(),
|
||||
(false, true) => string.element().trim_end().to_string(),
|
||||
(false, false) => return string,
|
||||
};
|
||||
|
||||
*string.element_mut() = result;
|
||||
string
|
||||
}
|
||||
|
||||
/// Converts between literal escape sequences and their corresponding control characters within a string.
|
||||
@@ -401,12 +432,18 @@ fn string_trim(
|
||||
fn string_escape(
|
||||
_: impl Ctx,
|
||||
/// The string that contains either literal escape sequences or control characters to be converted to the opposite representation.
|
||||
string: String,
|
||||
string: Item<String>,
|
||||
/// Convert the control characters back into their escape sequence representations.
|
||||
#[default(true)]
|
||||
unescape: bool,
|
||||
) -> String {
|
||||
if unescape { unescape_string(string) } else { escape_string(string) }
|
||||
unescape: Item<bool>,
|
||||
) -> Item<String> {
|
||||
let mut string = string;
|
||||
let input = std::mem::take(string.element_mut());
|
||||
|
||||
let result = if *unescape.element() { unescape_string(input) } else { escape_string(input) };
|
||||
|
||||
*string.element_mut() = result;
|
||||
string
|
||||
}
|
||||
|
||||
/// Reverses the sequence of characters making up the string so it reads back-to-front. ("Backwards text" becomes "txet sdrawkcaB".)
|
||||
@@ -414,9 +451,13 @@ fn string_escape(
|
||||
fn string_reverse(
|
||||
_: impl Ctx,
|
||||
/// The string to be reversed.
|
||||
string: String,
|
||||
) -> String {
|
||||
string.graphemes(true).rev().collect()
|
||||
string: Item<String>,
|
||||
) -> Item<String> {
|
||||
let mut string = string;
|
||||
let result: String = string.element().graphemes(true).rev().collect();
|
||||
|
||||
*string.element_mut() = result;
|
||||
string
|
||||
}
|
||||
|
||||
/// Repeats the string a given number of times, optionally with a separator between each repetition.
|
||||
@@ -424,31 +465,35 @@ fn string_reverse(
|
||||
fn string_repeat(
|
||||
_: impl Ctx,
|
||||
/// The string to be repeated.
|
||||
string: String,
|
||||
string: Item<String>,
|
||||
/// The number of times the string should appear in the output.
|
||||
#[default(2)]
|
||||
#[hard(1..)]
|
||||
count: u32,
|
||||
count: Item<u32>,
|
||||
/// The string placed between each repetition.
|
||||
#[default("\\n")]
|
||||
separator: String,
|
||||
separator: Item<String>,
|
||||
/// Whether to convert escape sequences found in the separator into their corresponding characters:
|
||||
/// "\n" (newline), "\r" (carriage return), "\t" (tab), "\0" (null), and "\\" (backslash).
|
||||
#[default(true)]
|
||||
separator_escaping: bool,
|
||||
) -> String {
|
||||
let separator = if separator_escaping { unescape_string(separator) } else { separator };
|
||||
separator_escaping: Item<bool>,
|
||||
) -> Item<String> {
|
||||
let mut string = string;
|
||||
let separator = separator.element().clone();
|
||||
let separator = if *separator_escaping.element() { unescape_string(separator) } else { separator };
|
||||
|
||||
let count = count as usize;
|
||||
let count = *count.element() as usize;
|
||||
|
||||
let mut result = String::with_capacity((string.len() + separator.len()) * count);
|
||||
let mut result = String::with_capacity((string.element().len() + separator.len()) * count);
|
||||
for i in 0..count {
|
||||
if i > 0 {
|
||||
result.push_str(&separator);
|
||||
}
|
||||
result.push_str(&string);
|
||||
result.push_str(string.element());
|
||||
}
|
||||
result
|
||||
|
||||
*string.element_mut() = result;
|
||||
string
|
||||
}
|
||||
|
||||
/// Pads the string to a target length by filling with the given repeated substring. If the string already meets or exceeds the target length, it is returned unchanged.
|
||||
@@ -456,21 +501,25 @@ fn string_repeat(
|
||||
fn string_pad(
|
||||
_: impl Ctx,
|
||||
/// The string to be padded to a target length.
|
||||
string: String,
|
||||
string: Item<String>,
|
||||
/// The target character length after padding. When "Up To" is set, this length concerns only the portion before (or after) that substring.
|
||||
#[default(10)]
|
||||
length: u32,
|
||||
length: Item<u32>,
|
||||
/// The repeated substring used to fill the remaining space. A multi-charcter substring may end partway through its final repetition.
|
||||
#[default("#")]
|
||||
padding: String,
|
||||
padding: Item<String>,
|
||||
/// Pad only the length of the string encountered before the start of the first (or after the end of the last) occurrence of this substring, if given and present (otherwise the full string is considered).
|
||||
///
|
||||
/// For example, this can pad numbers with leading zeros to align them before the decimal point.
|
||||
up_to: String,
|
||||
up_to: Item<String>,
|
||||
/// Pad at the end of the string instead of the start.
|
||||
from_end: bool,
|
||||
) -> String {
|
||||
let target_length = length as usize;
|
||||
from_end: Item<bool>,
|
||||
) -> Item<String> {
|
||||
let mut string = string;
|
||||
let target_length = *length.element() as usize;
|
||||
let padding = padding.element().clone();
|
||||
let up_to = up_to.element().clone();
|
||||
let from_end = *from_end.element();
|
||||
|
||||
if padding.is_empty() {
|
||||
return string;
|
||||
@@ -478,9 +527,9 @@ fn string_pad(
|
||||
|
||||
// Split the string at the "up to" substring if provided, and only pad that portion
|
||||
if !up_to.is_empty()
|
||||
&& let Some(position) = if from_end { string.rfind(&*up_to) } else { string.find(&*up_to) }
|
||||
&& let Some(position) = if from_end { string.element().rfind(&*up_to) } else { string.element().find(&*up_to) }
|
||||
{
|
||||
let (before, after) = string.split_at(position);
|
||||
let (before, after) = string.element().split_at(position);
|
||||
|
||||
if from_end {
|
||||
// Pad the portion after the substring
|
||||
@@ -491,7 +540,10 @@ fn string_pad(
|
||||
}
|
||||
let pad_length = target_length - current_length;
|
||||
let padding: String = padding.graphemes(true).cycle().take(pad_length).collect();
|
||||
return format!("{before}{up_to}{after_substring}{padding}");
|
||||
let result = format!("{before}{up_to}{after_substring}{padding}");
|
||||
|
||||
*string.element_mut() = result;
|
||||
return string;
|
||||
} else {
|
||||
// Pad the portion before the substring
|
||||
let current_length = before.graphemes(true).count();
|
||||
@@ -500,11 +552,14 @@ fn string_pad(
|
||||
}
|
||||
let pad_length = target_length - current_length;
|
||||
let padding: String = padding.graphemes(true).cycle().take(pad_length).collect();
|
||||
return format!("{padding}{before}{after}");
|
||||
let result = format!("{padding}{before}{after}");
|
||||
|
||||
*string.element_mut() = result;
|
||||
return string;
|
||||
}
|
||||
}
|
||||
|
||||
let current_length = string.graphemes(true).count();
|
||||
let current_length = string.element().graphemes(true).count();
|
||||
if current_length >= target_length {
|
||||
return string;
|
||||
}
|
||||
@@ -512,7 +567,10 @@ fn string_pad(
|
||||
let pad_length = target_length - current_length;
|
||||
let padding: String = padding.graphemes(true).cycle().take(pad_length).collect();
|
||||
|
||||
if from_end { string + &padding } else { padding + &string }
|
||||
let result = if from_end { string.element().clone() + &padding } else { padding + string.element() };
|
||||
|
||||
*string.element_mut() = result;
|
||||
string
|
||||
}
|
||||
|
||||
/// Checks whether the string contains the given substring. Optionally restricts the match to only the start and/or end of the string.
|
||||
@@ -520,20 +578,26 @@ fn string_pad(
|
||||
fn string_contains(
|
||||
_: impl Ctx,
|
||||
/// The string to search within.
|
||||
string: String,
|
||||
string: Item<String>,
|
||||
/// The substring to search for.
|
||||
substring: String,
|
||||
substring: Item<String>,
|
||||
/// Only match if the substring appears at the start of the string.
|
||||
at_start: bool,
|
||||
at_start: Item<bool>,
|
||||
/// Only match if the substring appears at the end of the string.
|
||||
at_end: bool,
|
||||
) -> bool {
|
||||
match (at_start, at_end) {
|
||||
(true, true) => string.starts_with(&*substring) && string.ends_with(&*substring),
|
||||
(true, false) => string.starts_with(&*substring),
|
||||
(false, true) => string.ends_with(&*substring),
|
||||
(false, false) => string.contains(&*substring),
|
||||
}
|
||||
at_end: Item<bool>,
|
||||
) -> Item<bool> {
|
||||
let (string, attributes) = string.into_parts();
|
||||
let substring = substring.element().as_str();
|
||||
let (at_start, at_end) = (*at_start.element(), *at_end.element());
|
||||
|
||||
let result = match (at_start, at_end) {
|
||||
(true, true) => string.starts_with(substring) && string.ends_with(substring),
|
||||
(true, false) => string.starts_with(substring),
|
||||
(false, true) => string.ends_with(substring),
|
||||
(false, false) => string.contains(substring),
|
||||
};
|
||||
|
||||
Item::from_parts(result, attributes)
|
||||
}
|
||||
|
||||
/// Similar to the **String Contains** node, this searches within the input string for the first (or last) occurrence of a substring and returns the index of where that begins, or -1 if not found.
|
||||
@@ -541,28 +605,35 @@ fn string_contains(
|
||||
fn string_find_index(
|
||||
_: impl Ctx,
|
||||
/// The string to search within.
|
||||
string: String,
|
||||
string: Item<String>,
|
||||
/// The substring to search for.
|
||||
substring: String,
|
||||
substring: Item<String>,
|
||||
/// Find the start index of the last occurrence instead of the first.
|
||||
from_end: bool,
|
||||
) -> f64 {
|
||||
from_end: Item<bool>,
|
||||
) -> Item<f64> {
|
||||
let (string, attributes) = string.into_parts();
|
||||
let substring = substring.element().as_str();
|
||||
let from_end = *from_end.element();
|
||||
|
||||
if substring.is_empty() {
|
||||
return if from_end { string.graphemes(true).count() as f64 } else { 0. };
|
||||
let result = if from_end { string.graphemes(true).count() as f64 } else { 0. };
|
||||
return Item::from_parts(result, attributes);
|
||||
}
|
||||
|
||||
if from_end {
|
||||
let result = if from_end {
|
||||
// Search backwards by finding all byte-level matches and taking the last one
|
||||
string
|
||||
.rmatch_indices(&*substring)
|
||||
.rmatch_indices(substring)
|
||||
.next()
|
||||
.map_or(-1., |(byte_index, _)| string[..byte_index].graphemes(true).count() as f64)
|
||||
} else {
|
||||
string
|
||||
.match_indices(&*substring)
|
||||
.match_indices(substring)
|
||||
.next()
|
||||
.map_or(-1., |(byte_index, _)| string[..byte_index].graphemes(true).count() as f64)
|
||||
}
|
||||
};
|
||||
|
||||
Item::from_parts(result, attributes)
|
||||
}
|
||||
|
||||
/// Counts the number of occurrences of a substring within the string.
|
||||
@@ -570,22 +641,25 @@ fn string_find_index(
|
||||
fn string_occurrences(
|
||||
_: impl Ctx,
|
||||
/// The string to search within.
|
||||
string: String,
|
||||
string: Item<String>,
|
||||
/// The substring to count occurrences of.
|
||||
substring: String,
|
||||
substring: Item<String>,
|
||||
/// Whether to count overlapping occurrences, using the substring as a sliding window.
|
||||
///
|
||||
/// For example, "aa" occurs twice in "aaaa" without overlapping but three times with overlapping.
|
||||
overlapping: bool,
|
||||
) -> f64 {
|
||||
overlapping: Item<bool>,
|
||||
) -> Item<f64> {
|
||||
let (string, attributes) = string.into_parts();
|
||||
let substring = substring.element().as_str();
|
||||
|
||||
if substring.is_empty() {
|
||||
return 0.;
|
||||
return Item::from_parts(0., attributes);
|
||||
}
|
||||
|
||||
// NON-OVERLAPPING: Simple linear scan.
|
||||
// O(n), where n = string length
|
||||
if !overlapping {
|
||||
return string.matches(&*substring).count() as f64;
|
||||
if !*overlapping.element() {
|
||||
return Item::from_parts(string.matches(substring).count() as f64, attributes);
|
||||
}
|
||||
|
||||
// OVERLAPPING: KMP (Knuth-Morris-Pratt) algorithm.
|
||||
@@ -631,7 +705,7 @@ fn string_occurrences(
|
||||
}
|
||||
}
|
||||
|
||||
count as f64
|
||||
Item::from_parts(count as f64, attributes)
|
||||
}
|
||||
|
||||
/// Converts a string's capitalization style to another of the common upper and lower case patterns, optionally joining words with a chosen separator.
|
||||
@@ -639,47 +713,49 @@ fn string_occurrences(
|
||||
fn string_capitalization(
|
||||
_: impl Ctx,
|
||||
/// The string to have its letter capitalization converted.
|
||||
string: String,
|
||||
string: Item<String>,
|
||||
/// The capitalization style to apply.
|
||||
capitalization: StringCapitalization,
|
||||
capitalization: Item<StringCapitalization>,
|
||||
/// Whether to split the string into words and reconnect with the chosen joiner. When disabled, the existing word structure separators are preserved.
|
||||
use_joiner: bool,
|
||||
use_joiner: Item<bool>,
|
||||
/// The string placed between each word.
|
||||
joiner: String,
|
||||
) -> String {
|
||||
joiner: Item<String>,
|
||||
) -> Item<String> {
|
||||
let mut string = string;
|
||||
let capitalization = *capitalization.element();
|
||||
let use_joiner = *use_joiner.element();
|
||||
let joiner = joiner.element().clone();
|
||||
let input = std::mem::take(string.element_mut());
|
||||
|
||||
// When the joiner is enabled, apply word-level casing and optionally reconnect words with the selected joiner
|
||||
if use_joiner {
|
||||
let result = if use_joiner {
|
||||
match capitalization {
|
||||
// Simple case mappings that preserve the string's existing structure
|
||||
StringCapitalization::LowerCase => string.to_lowercase(),
|
||||
StringCapitalization::UpperCase => string.to_uppercase(),
|
||||
StringCapitalization::LowerCase => input.to_lowercase(),
|
||||
StringCapitalization::UpperCase => input.to_uppercase(),
|
||||
|
||||
// Word-aware capitalizations that split on word boundaries and rejoin with the joiner
|
||||
StringCapitalization::CapitalCase => Converter::new().set_boundaries(&Boundary::defaults()).set_pattern(pattern::capital).set_delim(&joiner).convert(&string),
|
||||
StringCapitalization::CapitalCase => Converter::new().set_boundaries(&Boundary::defaults()).set_pattern(pattern::capital).set_delim(&joiner).convert(&input),
|
||||
StringCapitalization::HeadlineCase => {
|
||||
// First split into words with convert_case so word boundaries like "AlphaNumeric" are detected consistently with other modes,
|
||||
// then apply the titlecase crate for smart capitalization (lowercasing short words like "of", "the", etc.),
|
||||
// then rejoin with the custom joiner without mangling the capitalization
|
||||
let spaced = Converter::new().set_boundaries(&Boundary::defaults()).set_pattern(pattern::capital).set_delim(" ").convert(&string);
|
||||
let spaced = Converter::new().set_boundaries(&Boundary::defaults()).set_pattern(pattern::capital).set_delim(" ").convert(&input);
|
||||
let headline = titlecase::titlecase(&spaced);
|
||||
Converter::new().set_boundaries(&[Boundary::SPACE]).set_pattern(pattern::noop).set_delim(&joiner).convert(&headline)
|
||||
}
|
||||
StringCapitalization::SentenceCase => Converter::new()
|
||||
.set_boundaries(&Boundary::defaults())
|
||||
.set_pattern(pattern::sentence)
|
||||
.set_delim(&joiner)
|
||||
.convert(&string),
|
||||
StringCapitalization::CamelCase => Converter::new().set_boundaries(&Boundary::defaults()).set_pattern(pattern::camel).set_delim(&joiner).convert(&string),
|
||||
StringCapitalization::SentenceCase => Converter::new().set_boundaries(&Boundary::defaults()).set_pattern(pattern::sentence).set_delim(&joiner).convert(&input),
|
||||
StringCapitalization::CamelCase => Converter::new().set_boundaries(&Boundary::defaults()).set_pattern(pattern::camel).set_delim(&joiner).convert(&input),
|
||||
}
|
||||
}
|
||||
// When the joiner is disabled, apply only character-level casing while preserving the string's existing structure
|
||||
else {
|
||||
match capitalization {
|
||||
StringCapitalization::LowerCase => string.to_lowercase(),
|
||||
StringCapitalization::UpperCase => string.to_uppercase(),
|
||||
StringCapitalization::LowerCase => input.to_lowercase(),
|
||||
StringCapitalization::UpperCase => input.to_uppercase(),
|
||||
StringCapitalization::CapitalCase => {
|
||||
let mut capitalize_next = true;
|
||||
string.chars().fold(String::with_capacity(string.len()), |mut result, c| {
|
||||
input.chars().fold(String::with_capacity(input.len()), |mut result, c| {
|
||||
if c.is_whitespace() || c == '_' || c == '-' {
|
||||
capitalize_next = true;
|
||||
result.push(c);
|
||||
@@ -692,9 +768,9 @@ fn string_capitalization(
|
||||
result
|
||||
})
|
||||
}
|
||||
StringCapitalization::HeadlineCase => titlecase::titlecase(&string),
|
||||
StringCapitalization::HeadlineCase => titlecase::titlecase(&input),
|
||||
StringCapitalization::SentenceCase => {
|
||||
let mut chars = string.chars();
|
||||
let mut chars = input.chars();
|
||||
match chars.next() {
|
||||
Some(first) => first.to_uppercase().to_string() + &chars.as_str().to_lowercase(),
|
||||
None => String::new(),
|
||||
@@ -702,7 +778,7 @@ fn string_capitalization(
|
||||
}
|
||||
StringCapitalization::CamelCase => {
|
||||
let mut capitalize_next = false;
|
||||
string.chars().fold(String::with_capacity(string.len()), |mut result, c| {
|
||||
input.chars().fold(String::with_capacity(input.len()), |mut result, c| {
|
||||
if c.is_whitespace() || c == '_' || c == '-' {
|
||||
capitalize_next = true;
|
||||
result.push(c);
|
||||
@@ -716,15 +792,20 @@ fn string_capitalization(
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
*string.element_mut() = result;
|
||||
string
|
||||
}
|
||||
|
||||
// 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.graphemes(true).count() as f64
|
||||
fn string_length(_: impl Ctx, string: Item<String>) -> Item<f64> {
|
||||
let (string, attributes) = string.into_parts();
|
||||
|
||||
Item::from_parts(string.graphemes(true).count() as f64, attributes)
|
||||
}
|
||||
|
||||
/// Splits a string into a list of substrings based on the specified delimiter. This is the inverse of the **String Join** node.
|
||||
@@ -734,18 +815,19 @@ fn string_length(_: impl Ctx, string: String) -> f64 {
|
||||
fn string_split(
|
||||
_: impl Ctx,
|
||||
/// The string to split into substrings.
|
||||
string: String,
|
||||
string: Item<String>,
|
||||
/// The character(s) that separate the substrings. These are not included in the outputs.
|
||||
#[default("\\n")]
|
||||
delimiter: String,
|
||||
delimiter: Item<String>,
|
||||
/// Whether to convert escape sequences found in the delimiter into their corresponding characters:
|
||||
/// "\n" (newline), "\r" (carriage return), "\t" (tab), "\0" (null), and "\\" (backslash).
|
||||
#[default(true)]
|
||||
delimiter_escaping: bool,
|
||||
delimiter_escaping: Item<bool>,
|
||||
) -> List<String> {
|
||||
let delimiter = if delimiter_escaping { unescape_string(delimiter) } else { delimiter };
|
||||
let delimiter = delimiter.element().clone();
|
||||
let delimiter = if *delimiter_escaping.element() { unescape_string(delimiter) } else { delimiter };
|
||||
|
||||
string.split(&delimiter).map(str::to_string).map(Item::new_from_element).collect()
|
||||
string.element().split(&delimiter).map(str::to_string).map(Item::new_from_element).collect()
|
||||
}
|
||||
|
||||
/// Joins a list of strings together with a separator between each pair. This is the inverse of the **String Split** node.
|
||||
@@ -758,15 +840,18 @@ fn string_join(
|
||||
strings: List<String>,
|
||||
/// The text placed between each pair of strings.
|
||||
#[default(", ")]
|
||||
separator: String,
|
||||
separator: Item<String>,
|
||||
/// Whether to convert escape sequences found in the separator into their corresponding characters:
|
||||
/// "\n" (newline), "\r" (carriage return), "\t" (tab), "\0" (null), and "\\" (backslash).
|
||||
#[default(true)]
|
||||
separator_escaping: bool,
|
||||
) -> String {
|
||||
separator_escaping: Item<bool>,
|
||||
) -> Item<String> {
|
||||
let (separator, separator_escaping) = (separator.into_element(), separator_escaping.into_element());
|
||||
let separator = if separator_escaping { unescape_string(separator) } else { separator };
|
||||
|
||||
strings.iter_element_values().map(|s| s.as_str()).collect::<Vec<_>>().join(&separator)
|
||||
let joined = strings.iter_element_values().map(|s| s.as_str()).collect::<Vec<_>>().join(&separator);
|
||||
|
||||
Item::new_from_element(joined)
|
||||
}
|
||||
|
||||
/// Iterates over a list of strings, evaluating the mapped operation for each one. Use the **Read String** node to access the current string inside the loop.
|
||||
@@ -794,15 +879,19 @@ fn map_string(
|
||||
|
||||
/// Reads the current string from within a **Map String** node's loop.
|
||||
#[node_macro::node(category("Context"))]
|
||||
fn read_string(ctx: impl Ctx + ExtractVarArgs) -> String {
|
||||
let Ok(var_arg) = ctx.vararg(0) else { return String::new() };
|
||||
fn read_string(ctx: impl Ctx + ExtractVarArgs) -> Item<String> {
|
||||
let Ok(var_arg) = ctx.vararg(0) else { return Item::new_from_element(String::new()) };
|
||||
let var_arg = var_arg as &dyn std::any::Any;
|
||||
|
||||
var_arg.downcast_ref::<String>().cloned().unwrap_or_default()
|
||||
var_arg.downcast_ref::<Item<String>>().cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Converts a value to a JSON string representation.
|
||||
#[node_macro::node(category("Debug"))]
|
||||
fn serialize<T: serde::Serialize>(_: impl Ctx, #[implementations(String, bool, f64, u32, u64, DVec2, DAffine2)] value: T) -> String {
|
||||
serde_json::to_string(&value).unwrap_or_else(|_| "Serialization Error".to_string())
|
||||
fn serialize<T: serde::Serialize>(_: impl Ctx, #[implementations(String, bool, f64, u32, u64, DVec2, DAffine2)] value: Item<T>) -> Item<String> {
|
||||
let (value, attributes) = value.into_parts();
|
||||
|
||||
let result = serde_json::to_string(&value).unwrap_or_else(|_| "Serialization Error".to_string());
|
||||
|
||||
Item::from_parts(result, attributes)
|
||||
}
|
||||
|
||||
@@ -8,13 +8,14 @@ use skrifa::outline::{DrawSettings, OutlinePen};
|
||||
use skrifa::raw::FontRef as ReadFontsRef;
|
||||
use skrifa::{MetadataProvider, OutlineGlyph};
|
||||
use vector_types::ATTR_EDITOR_CLICK_TARGET;
|
||||
use vector_types::subpath::{ManipulatorGroup, Subpath};
|
||||
use vector_types::vector::{PointId, Vector};
|
||||
use vector_types::kurbo::{Affine, BezPath, Point, Rect, Shape};
|
||||
use vector_types::vector::Vector;
|
||||
use vector_types::vector::VectorExt;
|
||||
|
||||
pub struct PathBuilder {
|
||||
current_subpath: Subpath<PointId>,
|
||||
origin: DVec2,
|
||||
glyph_subpaths: Vec<Subpath<PointId>>,
|
||||
/// Contours of the glyph currently being drawn, accumulated as a single path.
|
||||
glyph_bezpath: BezPath,
|
||||
pub vector_list: List<Vector>,
|
||||
/// Per-glyph AABBs collected in single-item mode, published as `ATTR_EDITOR_CLICK_TARGET` in `finalize()`.
|
||||
merged_click_target_bboxes: Vec<[DVec2; 2]>,
|
||||
@@ -28,14 +29,12 @@ pub struct PathBuilder {
|
||||
/// `local_transforms` stays stable when all glyphs are clipped during a resize drag.
|
||||
first_glyph_offset: DVec2,
|
||||
scale: f64,
|
||||
id: PointId,
|
||||
}
|
||||
|
||||
impl PathBuilder {
|
||||
pub fn new(per_glyph_items: bool, scale: f64, text_frame_size: DVec2, first_glyph_offset: DVec2) -> Self {
|
||||
Self {
|
||||
current_subpath: Subpath::new(Vec::new(), false),
|
||||
glyph_subpaths: Vec::new(),
|
||||
glyph_bezpath: BezPath::new(),
|
||||
vector_list: if per_glyph_items { List::new() } else { List::new_from_element(Vector::default()) },
|
||||
merged_click_target_bboxes: Vec::new(),
|
||||
merged_click_target_baselines: Vec::new(),
|
||||
@@ -43,13 +42,12 @@ impl PathBuilder {
|
||||
text_frame_size,
|
||||
first_glyph_offset,
|
||||
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
|
||||
fn point(&self, x: f32, y: f32) -> Point {
|
||||
Point::new((self.origin.x + x as f64) * self.scale, (self.origin.y - y as f64) * self.scale)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -66,26 +64,23 @@ impl PathBuilder {
|
||||
let location_ref = LocationRef::new(normalized_coords);
|
||||
let settings = DrawSettings::unhinted(Size::new(size), location_ref);
|
||||
glyph.draw(settings, self).unwrap();
|
||||
let has_geometry = !self.glyph_subpaths.is_empty();
|
||||
let has_geometry = !self.glyph_bezpath.is_empty();
|
||||
|
||||
// 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 let Some(style_skew) = style_skew {
|
||||
self.glyph_bezpath.apply_affine(Affine::new(style_skew.to_cols_array()));
|
||||
}
|
||||
self.glyph_bezpath.apply_affine(Affine::new(skew.to_cols_array()));
|
||||
|
||||
let glyph_bbox = subpaths_bounding_box(&self.glyph_subpaths);
|
||||
let glyph_bbox = bezpath_bounding_box(&self.glyph_bezpath);
|
||||
|
||||
if per_glyph_items {
|
||||
// Frame in item-local space: top-left at `-glyph_offset` so the item transform cancels it
|
||||
// back to the layer-local frame origin, regardless of which glyph survived
|
||||
let frame_in_item_local = DAffine2::from_scale_angle_translation(self.text_frame_size, 0., -glyph_offset);
|
||||
|
||||
let item = Item::new_from_element(Vector::from_subpaths(core::mem::take(&mut self.glyph_subpaths), false))
|
||||
let item = Item::new_from_element(Vector::from_bezpath(core::mem::take(&mut self.glyph_bezpath)))
|
||||
.with_attribute(ATTR_TRANSFORM, DAffine2::from_translation(glyph_offset))
|
||||
.with_attribute(ATTR_EDITOR_TEXT_FRAME, frame_in_item_local);
|
||||
self.vector_list.push(item);
|
||||
@@ -93,10 +88,9 @@ impl PathBuilder {
|
||||
// Defer click target creation to `finalize()` where adjacent AABBs get widened
|
||||
self.per_glyph_bboxes.push(glyph_bbox);
|
||||
} else {
|
||||
for subpath in self.glyph_subpaths.drain(..) {
|
||||
// Unwrapping here is ok because `self.vector_list` is initialized with a single `List<Vector>` item
|
||||
self.vector_list.element_mut(0).unwrap().append_subpath(subpath, false);
|
||||
}
|
||||
// Unwrapping here is ok because `self.vector_list` is initialized with a single `List<Vector>` item
|
||||
self.vector_list.element_mut(0).unwrap().append_bezpath(core::mem::take(&mut self.glyph_bezpath));
|
||||
|
||||
if let Some(bbox) = glyph_bbox {
|
||||
self.merged_click_target_bboxes.push(bbox);
|
||||
self.merged_click_target_baselines.push(glyph_offset.y);
|
||||
@@ -197,18 +191,21 @@ impl PathBuilder {
|
||||
// Project back to glyph-local and stamp as click targets
|
||||
for (entry, widened) in entries.iter().zip(layer_bboxes.iter()) {
|
||||
let glyph_local = [widened[0] - entry.1, widened[1] - entry.1];
|
||||
let rect = Subpath::new_rectangle(glyph_local[0], glyph_local[1]);
|
||||
self.vector_list.set_attribute(ATTR_EDITOR_CLICK_TARGET, entry.0, Some(Vector::from_subpaths([rect], false)));
|
||||
let rect = rectangle_bezpath(glyph_local[0], glyph_local[1]);
|
||||
self.vector_list.set_attribute(ATTR_EDITOR_CLICK_TARGET, entry.0, Some(Vector::from_bezpath(rect)));
|
||||
}
|
||||
}
|
||||
|
||||
// "Separate Glyphs" off: widen the accumulated AABBs and bundle as one override `Vector`
|
||||
// Glyph separation off: widen the accumulated AABBs and bundle as one override `Vector`
|
||||
if !self.merged_click_target_bboxes.is_empty() {
|
||||
let mut bboxes = self.merged_click_target_bboxes;
|
||||
widen_horizontal_gaps(&mut bboxes, &self.merged_click_target_baselines);
|
||||
|
||||
let widened_subpaths: Vec<_> = bboxes.iter().map(|[min, max]| Subpath::new_rectangle(*min, *max)).collect();
|
||||
self.vector_list.set_attribute(ATTR_EDITOR_CLICK_TARGET, 0, Some(Vector::from_subpaths(widened_subpaths, false)));
|
||||
let mut widened_bezpath = BezPath::new();
|
||||
for [min, max] in &bboxes {
|
||||
widened_bezpath.extend(rectangle_bezpath(*min, *max));
|
||||
}
|
||||
self.vector_list.set_attribute(ATTR_EDITOR_CLICK_TARGET, 0, Some(Vector::from_bezpath(widened_bezpath)));
|
||||
}
|
||||
|
||||
// Fill in text frame for items that don't have one yet (single-item mode, where item 0 = identity)
|
||||
@@ -253,40 +250,37 @@ fn widen_horizontal_gaps(bboxes: &mut [[DVec2; 2]], baselines: &[f64]) {
|
||||
}
|
||||
}
|
||||
|
||||
fn subpaths_bounding_box(subpaths: &[Subpath<PointId>]) -> Option<[DVec2; 2]> {
|
||||
subpaths
|
||||
.iter()
|
||||
.filter_map(|subpath| subpath.bounding_box())
|
||||
.reduce(|[a_min, a_max], [b_min, b_max]| [a_min.min(b_min), a_max.max(b_max)])
|
||||
fn bezpath_bounding_box(bezpath: &BezPath) -> Option<[DVec2; 2]> {
|
||||
if bezpath.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let rect = bezpath.bounding_box();
|
||||
Some([DVec2::new(rect.x0, rect.y0), DVec2::new(rect.x1, rect.y1)])
|
||||
}
|
||||
|
||||
fn rectangle_bezpath(corner1: DVec2, corner2: DVec2) -> BezPath {
|
||||
Rect::new(corner1.x, corner1.y, corner2.x, corner2.y).to_path(0.)
|
||||
}
|
||||
|
||||
impl OutlinePen for PathBuilder {
|
||||
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()));
|
||||
self.glyph_bezpath.move_to(self.point(x, y));
|
||||
}
|
||||
|
||||
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()));
|
||||
self.glyph_bezpath.line_to(self.point(x, y));
|
||||
}
|
||||
|
||||
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()));
|
||||
self.glyph_bezpath.quad_to(self.point(x1, y1), self.point(x2, y2));
|
||||
}
|
||||
|
||||
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()));
|
||||
self.glyph_bezpath.curve_to(self.point(x1, y1), self.point(x2, y2), self.point(x3, y3));
|
||||
}
|
||||
|
||||
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)));
|
||||
self.glyph_bezpath.close_path();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,18 +7,22 @@ use core_types::{ATTR_END, ATTR_NAME, ATTR_START, Ctx};
|
||||
fn regex_contains(
|
||||
_: impl Ctx,
|
||||
/// The string to search within.
|
||||
string: String,
|
||||
string: Item<String>,
|
||||
/// The regular expression pattern to search for.
|
||||
pattern: String,
|
||||
pattern: Item<String>,
|
||||
/// Match letters regardless of case.
|
||||
case_insensitive: bool,
|
||||
case_insensitive: Item<bool>,
|
||||
/// Make `^` and `$` match the start and end of each line, not just the whole string.
|
||||
multiline: bool,
|
||||
multiline: Item<bool>,
|
||||
/// Only match if the pattern appears at the start of the string.
|
||||
at_start: bool,
|
||||
at_start: Item<bool>,
|
||||
/// Only match if the pattern appears at the end of the string.
|
||||
at_end: bool,
|
||||
) -> bool {
|
||||
at_end: Item<bool>,
|
||||
) -> Item<bool> {
|
||||
let (string, attributes) = string.into_parts();
|
||||
let pattern = pattern.element();
|
||||
let (case_insensitive, multiline, at_start, at_end) = (*case_insensitive.element(), *multiline.element(), *at_start.element(), *at_end.element());
|
||||
|
||||
let flags = match (case_insensitive, multiline) {
|
||||
(false, false) => "",
|
||||
(true, false) => "(?i)",
|
||||
@@ -34,29 +38,34 @@ fn regex_contains(
|
||||
|
||||
let Ok(regex) = fancy_regex::Regex::new(&anchored_pattern) else {
|
||||
log::error!("Invalid regex pattern: {pattern}");
|
||||
return false;
|
||||
return Item::from_parts(false, attributes);
|
||||
};
|
||||
|
||||
regex.is_match(&string).unwrap_or(false)
|
||||
Item::from_parts(regex.is_match(&string).unwrap_or(false), attributes)
|
||||
}
|
||||
|
||||
/// Replaces matches of a regular expression pattern in the string. The replacement string can reference captures: `$0` for the whole match and `$1`, `$2`, etc. for capture groups.
|
||||
#[node_macro::node(category("Text: Regex"))]
|
||||
fn regex_replace(
|
||||
_: impl Ctx,
|
||||
string: String,
|
||||
string: Item<String>,
|
||||
/// The regular expression pattern to search for.
|
||||
pattern: String,
|
||||
pattern: Item<String>,
|
||||
/// The replacement string. Use `$0` for the whole match and `$1`, `$2`, etc. for capture groups.
|
||||
replacement: String,
|
||||
replacement: Item<String>,
|
||||
/// Replace all matches. When disabled, only the first match is replaced.
|
||||
#[default(true)]
|
||||
replace_all: bool,
|
||||
replace_all: Item<bool>,
|
||||
/// Match letters regardless of case.
|
||||
case_insensitive: bool,
|
||||
case_insensitive: Item<bool>,
|
||||
/// Make `^` and `$` match the start and end of each line, not just the whole string.
|
||||
multiline: bool,
|
||||
) -> String {
|
||||
multiline: Item<bool>,
|
||||
) -> Item<String> {
|
||||
let mut string = string;
|
||||
let pattern = pattern.element().clone();
|
||||
let replacement = replacement.element().clone();
|
||||
let (replace_all, case_insensitive, multiline) = (*replace_all.element(), *case_insensitive.element(), *multiline.element());
|
||||
|
||||
let flags = match (case_insensitive, multiline) {
|
||||
(false, false) => "",
|
||||
(true, false) => "(?i)",
|
||||
@@ -70,11 +79,14 @@ fn regex_replace(
|
||||
return string;
|
||||
};
|
||||
|
||||
if replace_all {
|
||||
regex.replace_all(&string, replacement.as_str()).into_owned()
|
||||
let result = if replace_all {
|
||||
regex.replace_all(string.element(), replacement.as_str()).into_owned()
|
||||
} else {
|
||||
regex.replace(&string, replacement.as_str()).into_owned()
|
||||
}
|
||||
regex.replace(string.element(), replacement.as_str()).into_owned()
|
||||
};
|
||||
|
||||
*string.element_mut() = result;
|
||||
string
|
||||
}
|
||||
|
||||
/// Finds a regex match in the string and returns its components. The result is a list where the first item is the whole match (`$0`) and subsequent items are the capture groups (`$1`, `$2`, etc., if any).
|
||||
@@ -87,16 +99,20 @@ fn regex_replace(
|
||||
fn regex_find(
|
||||
_: impl Ctx,
|
||||
/// The string to search within.
|
||||
string: String,
|
||||
string: Item<String>,
|
||||
/// The regular expression pattern to search for.
|
||||
pattern: String,
|
||||
pattern: Item<String>,
|
||||
/// Which non-overlapping occurrence of the pattern to return, starting from 0 for the first match. Negative indices count backwards from the last match.
|
||||
match_index: SignedInteger,
|
||||
match_index: Item<SignedInteger>,
|
||||
/// Match letters regardless of case.
|
||||
case_insensitive: bool,
|
||||
case_insensitive: Item<bool>,
|
||||
/// Make `^` and `$` match the start and end of each line, not just the whole string.
|
||||
multiline: bool,
|
||||
multiline: Item<bool>,
|
||||
) -> List<String> {
|
||||
let string = string.element();
|
||||
let pattern = pattern.element();
|
||||
let (match_index, case_insensitive, multiline) = (*match_index.element(), *case_insensitive.element(), *multiline.element());
|
||||
|
||||
if pattern.is_empty() {
|
||||
return List::new();
|
||||
}
|
||||
@@ -118,7 +134,7 @@ fn regex_find(
|
||||
let capture_names: Vec<Option<String>> = regex.capture_names().map(|name| name.map(str::to_string)).collect();
|
||||
|
||||
// Collect all matches since we need to support negative indexing
|
||||
let matches: Vec<_> = regex.captures_iter(&string).filter_map(|c| c.ok()).collect();
|
||||
let matches: Vec<_> = regex.captures_iter(string).filter_map(|c| c.ok()).collect();
|
||||
|
||||
let match_index = match_index as i32;
|
||||
let resolved_index = if match_index < 0 {
|
||||
@@ -158,14 +174,18 @@ fn regex_find(
|
||||
fn regex_find_all(
|
||||
_: impl Ctx,
|
||||
/// The string to search within.
|
||||
string: String,
|
||||
string: Item<String>,
|
||||
/// The regular expression pattern to search for.
|
||||
pattern: String,
|
||||
pattern: Item<String>,
|
||||
/// Match letters regardless of case.
|
||||
case_insensitive: bool,
|
||||
case_insensitive: Item<bool>,
|
||||
/// Make `^` and `$` match the start and end of each line, not just the whole string.
|
||||
multiline: bool,
|
||||
multiline: Item<bool>,
|
||||
) -> List<String> {
|
||||
let string = string.element();
|
||||
let pattern = pattern.element();
|
||||
let (case_insensitive, multiline) = (*case_insensitive.element(), *multiline.element());
|
||||
|
||||
if pattern.is_empty() {
|
||||
return List::new();
|
||||
}
|
||||
@@ -184,7 +204,7 @@ fn regex_find_all(
|
||||
};
|
||||
|
||||
regex
|
||||
.find_iter(&string)
|
||||
.find_iter(string)
|
||||
.filter_map(|m| m.ok())
|
||||
.map(|m| {
|
||||
Item::new_from_element(m.as_str().to_string())
|
||||
@@ -201,16 +221,19 @@ fn regex_find_all(
|
||||
fn regex_split(
|
||||
_: impl Ctx,
|
||||
/// The string to split into substrings.
|
||||
string: String,
|
||||
string: Item<String>,
|
||||
/// The regular expression pattern to split on. Matches are consumed and not included in the output.
|
||||
pattern: String,
|
||||
pattern: Item<String>,
|
||||
/// Match letters regardless of case.
|
||||
case_insensitive: bool,
|
||||
case_insensitive: Item<bool>,
|
||||
/// Make `^` and `$` match the start and end of each line, not just the whole string.
|
||||
multiline: bool,
|
||||
multiline: Item<bool>,
|
||||
) -> List<String> {
|
||||
let pattern = pattern.element().clone();
|
||||
let (case_insensitive, multiline) = (*case_insensitive.element(), *multiline.element());
|
||||
|
||||
if pattern.is_empty() {
|
||||
return List::new_from_element(string);
|
||||
return List::new_from_item(string);
|
||||
}
|
||||
|
||||
let flags = match (case_insensitive, multiline) {
|
||||
@@ -223,8 +246,8 @@ fn regex_split(
|
||||
|
||||
let Ok(regex) = fancy_regex::Regex::new(&full_pattern) else {
|
||||
log::error!("Invalid regex pattern: {pattern}");
|
||||
return List::new_from_element(string);
|
||||
return List::new_from_item(string);
|
||||
};
|
||||
|
||||
regex.split(&string).filter_map(|s| s.ok()).map(|s| s.to_string()).map(Item::new_from_element).collect()
|
||||
regex.split(string.element()).filter_map(|s| s.ok()).map(|s| s.to_string()).map(Item::new_from_element).collect()
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::TypesettingConfig;
|
||||
use super::text_context::TextContext;
|
||||
use crate::markers::{ATTR_FONT, ATTR_TEXT_ALIGN};
|
||||
use core_types::blending::BlendMode;
|
||||
use core_types::list::List;
|
||||
use core_types::list::{Item, List, NodeIdPath};
|
||||
use core_types::uuid::NodeId;
|
||||
use core_types::{
|
||||
ATTR_BLEND_MODE, ATTR_EDITOR_LAYER_PATH, ATTR_FONT_SIZE, ATTR_LETTER_SPACING, ATTR_LETTER_TILT, ATTR_LINE_HEIGHT, ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM,
|
||||
@@ -23,9 +23,64 @@ pub fn lines_clipping(text: &str, font: &Resource, typesetting: TypesettingConfi
|
||||
TextContext::with_thread_local(|ctx| ctx.lines_clipping(text, font, typesetting))
|
||||
}
|
||||
|
||||
/// Shapes each string item of a styled `List<String>` into vector geometry, reading its font and typesetting
|
||||
/// from the item's attributes (as set by the 'Text' node) and re-applying its transform and blending
|
||||
/// attributes onto the produced paths. With `separate_glyphs`, each glyph becomes its own item.
|
||||
/// Shapes a single styled string item into vector geometry, reading its font and typesetting from the item's
|
||||
/// attributes (as set by the 'Text' node) and re-applying its transform and blending attributes onto the produced
|
||||
/// paths. With `separate_glyphs`, each glyph becomes its own item; otherwise a single compound path is produced.
|
||||
pub fn shape_text_item(item: &Item<String>, separate_glyphs: bool) -> List<Vector> {
|
||||
let text = item.element();
|
||||
if text.is_empty() {
|
||||
return List::new();
|
||||
}
|
||||
|
||||
// Use fallback font when none is explicitly attached.
|
||||
let font: Resource = {
|
||||
let font: Resource = item.attribute_cloned_or_default(ATTR_FONT);
|
||||
if font.is_empty() { super::FALLBACK_FONT_RESOURCE.clone() } else { font }
|
||||
};
|
||||
|
||||
let defaults = TypesettingConfig::default();
|
||||
let typesetting = TypesettingConfig {
|
||||
font_size: item.attribute_cloned_or(ATTR_FONT_SIZE, defaults.font_size),
|
||||
line_height_ratio: item.attribute_cloned_or(ATTR_LINE_HEIGHT, defaults.line_height_ratio),
|
||||
letter_spacing: item.attribute_cloned_or(ATTR_LETTER_SPACING, defaults.letter_spacing),
|
||||
letter_tilt: item.attribute_cloned_or(ATTR_LETTER_TILT, defaults.letter_tilt),
|
||||
max_width: item.attribute_cloned_or::<Option<f64>>(ATTR_MAX_WIDTH, defaults.max_width),
|
||||
max_height: item.attribute_cloned_or::<Option<f64>>(ATTR_MAX_HEIGHT, defaults.max_height),
|
||||
align: item.attribute_cloned_or(ATTR_TEXT_ALIGN, defaults.align),
|
||||
};
|
||||
|
||||
let vectors = to_path(text, &font, typesetting, separate_glyphs);
|
||||
let transform = item.attribute_cloned_or_default::<DAffine2>(ATTR_TRANSFORM);
|
||||
let layer_path = item.attribute::<NodeIdPath>(ATTR_EDITOR_LAYER_PATH).cloned();
|
||||
let blend_mode = item.attribute::<BlendMode>(ATTR_BLEND_MODE).copied();
|
||||
let opacity = item.attribute::<f64>(ATTR_OPACITY).copied();
|
||||
let opacity_fill = item.attribute::<f64>(ATTR_OPACITY_FILL).copied();
|
||||
|
||||
let mut result = List::new();
|
||||
for mut produced in vectors.into_iter() {
|
||||
if transform != DAffine2::IDENTITY {
|
||||
let local = produced.attribute_cloned_or_default::<DAffine2>(ATTR_TRANSFORM);
|
||||
produced.set_attribute(ATTR_TRANSFORM, transform * local);
|
||||
}
|
||||
if let Some(layer_path) = &layer_path {
|
||||
produced.set_attribute(ATTR_EDITOR_LAYER_PATH, layer_path.clone());
|
||||
}
|
||||
if let Some(blend_mode) = blend_mode {
|
||||
produced.set_attribute(ATTR_BLEND_MODE, blend_mode);
|
||||
}
|
||||
if let Some(opacity) = opacity {
|
||||
produced.set_attribute(ATTR_OPACITY, opacity);
|
||||
}
|
||||
if let Some(opacity_fill) = opacity_fill {
|
||||
produced.set_attribute(ATTR_OPACITY_FILL, opacity_fill);
|
||||
}
|
||||
result.push(produced);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Shapes each string item of a styled `List<String>` into vector geometry, flattening the per-item results.
|
||||
pub fn shape_text_list(strings: &List<String>, separate_glyphs: bool) -> List<Vector> {
|
||||
let mut result = List::new();
|
||||
|
||||
|
||||
@@ -6,10 +6,9 @@ use core_types::gpoll::{Extent, GPoll, Interrupt};
|
||||
use core_types::transform::{ApplyTransform, ScaleType, Transform};
|
||||
use core_types::{CacheHash, Context, Ctx, DeriveCtx, InjectFootprint, ModifyFootprint};
|
||||
use glam::{DAffine2, DMat2, DVec2};
|
||||
use graphic_types::Graphic;
|
||||
use graphic_types::Vector;
|
||||
use graphic_types::raster_types::{CPU, GPU, Raster};
|
||||
use vector_types::GradientStops;
|
||||
use graphic_types::{Artboard, Graphic, Vector};
|
||||
use vector_types::Gradient;
|
||||
|
||||
/// Applies the specified transform to each lane of the input, composing onto the lane's transform attribute.
|
||||
#[node_macro::node(category("Math: Transform"), extent(transform_extent))]
|
||||
@@ -57,7 +56,7 @@ fn transform_value<T: ApplyTransform + 'static>(
|
||||
let transformed = ctx.modify_footprint(|footprint| footprint.apply_transform(&matrix));
|
||||
let mut transform_target = content.eval(&transformed.ctx())?;
|
||||
|
||||
transform_target.left_apply_transform(&matrix);
|
||||
item.left_apply_transform(&matrix);
|
||||
|
||||
Ok(transform_target)
|
||||
}
|
||||
@@ -97,7 +96,7 @@ fn replace_transform<T>(_: impl Ctx + InjectFootprint, (element, _content_transf
|
||||
// TODO: Figure out how this node should behave once #2982 is implemented.
|
||||
/// Obtains the transform of the first lane of the input, if present.
|
||||
#[node_macro::node(category("Math: Transform"), path(core_types::vector))]
|
||||
fn extract_transform<T: Clone + Send + Sync + CacheHash + 'static>(_: impl Ctx, #[implementations(Graphic, Vector, Raster<CPU>, Raster<GPU>, Color, GradientStops)] content: IList<T>) -> DAffine2 {
|
||||
fn extract_transform<T: Clone + Send + Sync + CacheHash + 'static>(_: impl Ctx, #[implementations(Graphic, Vector, Raster<CPU>, Raster<GPU>, Color, Gradient)] content: IList<T>) -> DAffine2 {
|
||||
match content.len() {
|
||||
0 => DAffine2::default(),
|
||||
_ => content.lane(0).attr::<TransformAttr>(),
|
||||
@@ -107,19 +106,23 @@ fn extract_transform<T: Clone + Send + Sync + CacheHash + 'static>(_: impl Ctx,
|
||||
/// 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()
|
||||
let (transform, attributes) = transform.into_parts();
|
||||
|
||||
let result = transform.inverse();
|
||||
|
||||
Item::from_parts(result, attributes)
|
||||
}
|
||||
|
||||
/// Extracts the translation component from the input transform.
|
||||
#[node_macro::node(category("Math: Transform"))]
|
||||
fn decompose_translation(_: impl Ctx, transform: DAffine2) -> DVec2 {
|
||||
transform.translation
|
||||
Item::new_from_element(transform.into_element().translation)
|
||||
}
|
||||
|
||||
/// Extracts the rotation component (in degrees) from the input transform.
|
||||
#[node_macro::node(category("Math: Transform"))]
|
||||
fn decompose_rotation(_: impl Ctx, transform: DAffine2) -> f64 {
|
||||
transform.decompose_rotation().to_degrees()
|
||||
Item::new_from_element(transform.into_element().decompose_rotation().to_degrees())
|
||||
}
|
||||
|
||||
/// Extracts the scale component from the input transform.
|
||||
@@ -127,14 +130,19 @@ fn decompose_rotation(_: impl Ctx, transform: DAffine2) -> f64 {
|
||||
/// **Pure** returns the isolated scale factors with rotation and skew stripped away (can be negative for flipped axes).
|
||||
#[node_macro::node(category("Math: Transform"))]
|
||||
fn decompose_scale(_: impl Ctx, transform: DAffine2, scale_type: ScaleType) -> DVec2 {
|
||||
match scale_type {
|
||||
let transform = transform.into_element();
|
||||
let scale_type = scale_type.into_element();
|
||||
|
||||
let result = match scale_type {
|
||||
ScaleType::Magnitude => transform.scale_magnitudes(),
|
||||
ScaleType::Pure => transform.decompose_scale(),
|
||||
}
|
||||
};
|
||||
|
||||
Item::new_from_element(result)
|
||||
}
|
||||
|
||||
/// Extracts the skew angle (in degrees) from the input transform.
|
||||
#[node_macro::node(category("Math: Transform"))]
|
||||
fn decompose_skew(_: impl Ctx, transform: DAffine2) -> f64 {
|
||||
transform.decompose_skew().atan().to_degrees()
|
||||
Item::new_from_element(transform.into_element().decompose_skew().atan().to_degrees())
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ repeat-nodes = { workspace = true }
|
||||
dyn-any = { workspace = true }
|
||||
glam = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
delaunator = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
rustc-hash = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
@@ -3,44 +3,12 @@ use core_types::{CacheHash, Ctx};
|
||||
use dyn_any::DynAny;
|
||||
use glam::DVec2;
|
||||
use graphic_types::Vector;
|
||||
use vector_types::subpath;
|
||||
use vector_types::vector::misc::{ArcType, AsU64, GridType};
|
||||
use vector_types::vector::VectorExt;
|
||||
use vector_types::vector::algorithms::shapes;
|
||||
use vector_types::vector::misc::BezierHandles;
|
||||
use vector_types::vector::misc::{ArcType, AsU64, BoxCorners, GridType};
|
||||
use vector_types::vector::misc::{HandleId, SpiralType};
|
||||
use vector_types::vector::{PointId, SegmentId, StrokeId};
|
||||
|
||||
/// Expands the corner-radius lanes to four corners using the CSS
|
||||
/// `border-radius` shorthand rules, then builds the rounded rectangle.
|
||||
/// - `[a]` (also a plain scalar radius) expands to `[a, a, a, a]`
|
||||
/// - `[a, b]` expands to `[a, b, a, b]`
|
||||
/// - `[a, b, c]` expands to `[a, b, c, b]`
|
||||
/// - `[a, b, c, d, …]` truncates to `[a, b, c, d]`
|
||||
/// - `[]` expands to `[0, 0, 0, 0]`
|
||||
fn rounded_rectangle(values: &[f64], size: DVec2, clamped: bool) -> Vector {
|
||||
let radii: [f64; 4] = match values {
|
||||
[] => [0., 0., 0., 0.],
|
||||
&[a] => [a, a, a, a],
|
||||
&[a, b] => [a, b, a, b],
|
||||
&[a, b, c] => [a, b, c, b],
|
||||
&[a, b, c, d, ..] => [a, b, c, d],
|
||||
};
|
||||
|
||||
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 = radii[i] + radii[(i + 1) % 4];
|
||||
if side_length < adjacent_corner_radius_sum {
|
||||
scale_factor = scale_factor.min(side_length / adjacent_corner_radius_sum);
|
||||
}
|
||||
}
|
||||
radii.map(|x| x * scale_factor)
|
||||
} else {
|
||||
radii
|
||||
};
|
||||
Vector::from_subpath(subpath::Subpath::new_rounded_rectangle(size / -2., size / 2., clamped_radius))
|
||||
}
|
||||
use vector_types::vector::{PointId, SegmentId};
|
||||
|
||||
/// Generates a circle shape with a chosen radius.
|
||||
#[node_macro::node(category("Vector: Shape"))]
|
||||
@@ -52,7 +20,7 @@ fn circle(
|
||||
radius: f64,
|
||||
) -> Vector {
|
||||
let radius = radius.abs();
|
||||
Vector::from_subpath(subpath::Subpath::new_ellipse(DVec2::splat(-radius), DVec2::splat(radius)))
|
||||
Vector::from_bezpath(shapes::ellipse_bezpath(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.
|
||||
@@ -70,15 +38,11 @@ fn arc(
|
||||
sweep_angle: Angle,
|
||||
arc_type: ArcType,
|
||||
) -> Vector {
|
||||
Vector::from_subpath(subpath::Subpath::new_arc(
|
||||
Vector::from_bezpath(shapes::arc_bezpath(
|
||||
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,
|
||||
},
|
||||
arc_type,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -94,7 +58,7 @@ fn spiral(
|
||||
#[default(25)] outer_radius: f64,
|
||||
#[default(90.)] angular_resolution: f64,
|
||||
) -> Vector {
|
||||
Vector::from_subpath(subpath::Subpath::new_spiral(
|
||||
Vector::from_bezpath(shapes::spiral_bezpath(
|
||||
inner_radius,
|
||||
outer_radius,
|
||||
turns,
|
||||
@@ -120,7 +84,7 @@ fn ellipse(
|
||||
let corner1 = -radius;
|
||||
let corner2 = radius;
|
||||
|
||||
let mut ellipse = Vector::from_subpath(subpath::Subpath::new_ellipse(corner1, corner2));
|
||||
let mut ellipse = Vector::from_bezpath(shapes::ellipse_bezpath(corner1, corner2));
|
||||
|
||||
let len = ellipse.segment_domain.ids().len();
|
||||
for i in 0..len {
|
||||
@@ -143,12 +107,43 @@ fn rectangle(
|
||||
#[unit(" px")]
|
||||
#[default(100)]
|
||||
height: f64,
|
||||
_individual_corner_radii: bool, // TODO: Move this to the bottom once we have a migration capability
|
||||
corner_radius: IList<f64>,
|
||||
corner_radius: BoxCorners,
|
||||
#[default(true)] clamped: bool,
|
||||
_individual_corner_radii: bool,
|
||||
) -> Vector {
|
||||
let values: Vec<f64> = (0..corner_radius.len()).map(|index| corner_radius.get(index)).collect();
|
||||
rounded_rectangle(&values, DVec2::new(width, height), clamped)
|
||||
let size = DVec2::new(width, height);
|
||||
let radii = corner_radius.to_corner_values();
|
||||
|
||||
// Scale down overlapping adjacent radii to fit, following the CSS spec: <https://drafts.csswg.org/css-backgrounds/#corner-overlap>
|
||||
let radii = if clamped {
|
||||
let radii = radii.map(|radius| radius.max(0.));
|
||||
|
||||
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 = radii[i] + radii[(i + 1) % 4];
|
||||
if side_length < adjacent_corner_radius_sum {
|
||||
scale_factor = scale_factor.min((side_length / adjacent_corner_radius_sum).max(0.));
|
||||
}
|
||||
}
|
||||
|
||||
radii.map(|radius| radius * scale_factor)
|
||||
} else {
|
||||
radii
|
||||
};
|
||||
|
||||
Vector::from_bezpath(shapes::rounded_rectangle_bezpath(size / -2., size / 2., radii))
|
||||
}
|
||||
|
||||
/// Builds a set of four corner values, such as a rectangle's corner radii, from a list of one, two, three, or four values.
|
||||
#[node_macro::node(category("Vector: Shape"))]
|
||||
fn box_corners(
|
||||
_: impl Ctx,
|
||||
/// The corner values, filling the four corners clockwise from the top-left. Give one value for all corners, two for opposite pairs, three for top-left, the two sides, then bottom-right, or four for each corner.
|
||||
values: IList<f64>,
|
||||
) -> BoxCorners {
|
||||
let values: Vec<f64> = (0..values.len()).map(|index| values.get(index)).collect();
|
||||
BoxCorners::from(values)
|
||||
}
|
||||
|
||||
/// Generates an regular polygon shape like a triangle, square, pentagon, hexagon, heptagon, octagon, or any higher n-gon.
|
||||
@@ -165,8 +160,7 @@ fn regular_polygon<T: AsU64>(
|
||||
radius: f64,
|
||||
) -> Vector {
|
||||
let points = sides.as_u64();
|
||||
let radius: f64 = radius * 2.;
|
||||
Vector::from_subpath(subpath::Subpath::new_regular_polygon(DVec2::splat(-radius), points, radius))
|
||||
Vector::from_bezpath(shapes::regular_polygon_bezpath(DVec2::ZERO, points, radius))
|
||||
}
|
||||
|
||||
/// Generates an n-pointed star shape with inner and outer points at chosen radii from the center.
|
||||
@@ -186,10 +180,7 @@ fn star<T: AsU64>(
|
||||
radius_2: f64,
|
||||
) -> Vector {
|
||||
let points = sides.as_u64();
|
||||
let diameter: f64 = radius_1 * 2.;
|
||||
let inner_diameter = radius_2 * 2.;
|
||||
|
||||
Vector::from_subpath(subpath::Subpath::new_star_polygon(DVec2::splat(-diameter), points, diameter, inner_diameter))
|
||||
Vector::from_bezpath(shapes::star_polygon_bezpath(DVec2::ZERO, points, radius_1, radius_2))
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||||
@@ -242,11 +233,7 @@ fn qr_code(
|
||||
for x in 0..dimension {
|
||||
if qr_code.get_module(x as i32, y as i32) {
|
||||
let corner1 = DVec2::new(x as f64, y as f64);
|
||||
let corner2 = corner1 + DVec2::splat(1.);
|
||||
vector.append_subpath(
|
||||
subpath::Subpath::from_anchors([corner1, DVec2::new(corner2.x, corner1.y), corner2, DVec2::new(corner1.x, corner2.y)], true),
|
||||
false,
|
||||
);
|
||||
vector.append_bezpath(shapes::rectangle_bezpath(corner1, corner1 + DVec2::splat(1.)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -273,12 +260,12 @@ fn arrow(
|
||||
#[default(30)] head_width: PixelLength,
|
||||
#[default(20)] head_length: PixelLength,
|
||||
) -> Vector {
|
||||
Vector::from_subpath(subpath::Subpath::new_arrow(DVec2::ZERO, arrow_to, shaft_width, head_width, head_length))
|
||||
Vector::from_bezpath(shapes::arrow_bezpath(DVec2::ZERO, arrow_to, shaft_width, head_width, head_length))
|
||||
}
|
||||
|
||||
#[node_macro::node(category("Vector: Shape"))]
|
||||
fn line(_: impl Ctx, _primary: (), #[default(100., 100.)] line_to: PixelSize) -> Vector {
|
||||
Vector::from_subpath(subpath::Subpath::new_line(DVec2::ZERO, line_to))
|
||||
Vector::from_bezpath(shapes::line_bezpath(DVec2::ZERO, line_to))
|
||||
}
|
||||
|
||||
trait GridSpacing {
|
||||
@@ -309,84 +296,74 @@ fn grid<T: GridSpacing>(
|
||||
#[default(10)] columns: u32,
|
||||
#[default(10)] rows: u32,
|
||||
#[default(30., 30.)] angles: DVec2,
|
||||
#[default(true)] connect_cells: bool,
|
||||
) -> Vector {
|
||||
let (x_spacing, y_spacing) = spacing.as_dvec2().into();
|
||||
let (angle_a, angle_b) = angles.into();
|
||||
|
||||
// Isometric grid spacing based on the two skew angles. Unused for rectangular grids.
|
||||
let tan_a = angle_a.to_radians().tan();
|
||||
let tan_b = angle_b.to_radians().tan();
|
||||
let isometric_spacing = DVec2::new(y_spacing / (tan_a + tan_b), y_spacing);
|
||||
|
||||
// The position of the grid point at column `x`, row `y`.
|
||||
let position = |x: u32, y: u32| -> DVec2 {
|
||||
match grid_type {
|
||||
GridType::Rectangular => DVec2::new(x_spacing * x as f64, y_spacing * y as f64),
|
||||
GridType::Isometric => {
|
||||
// Odd columns are offset vertically so the cells skew into the isometric shape.
|
||||
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;
|
||||
DVec2::new(isometric_spacing.x * x as f64, isometric_spacing.y * y as f64 + offset_y_fraction * isometric_spacing.x)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// When the cells aren't connected, each one is its own closed quadrilateral subpath.
|
||||
// The vertices are ordered counter-clockwise to match the framework's fill winding.
|
||||
if !connect_cells {
|
||||
let mut cells = Vec::new();
|
||||
for y in 0..rows.saturating_sub(1) {
|
||||
for x in 0..columns.saturating_sub(1) {
|
||||
cells.push(vec![position(x, y), position(x + 1, y), position(x + 1, y + 1), position(x, y + 1)]);
|
||||
}
|
||||
}
|
||||
let mut vector = Vector::default();
|
||||
crate::vector_nodes::replace_with_polygons(&mut vector, cells, connect_cells);
|
||||
return vector;
|
||||
}
|
||||
|
||||
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));
|
||||
for y in 0..rows {
|
||||
for x in 0..columns {
|
||||
// Add the current point to the grid.
|
||||
let current_index = vector.point_domain.ids().len();
|
||||
vector.point_domain.push(point_id.next_id(), position(x, y));
|
||||
|
||||
// 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));
|
||||
// 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, BezierHandles::Linear);
|
||||
}
|
||||
}
|
||||
}
|
||||
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();
|
||||
// Connect to the point to the left (horizontal connection).
|
||||
push_segment((x > 0).then(|| current_index - 1));
|
||||
|
||||
let a_angles_eaten = x.div_ceil(2) as f64;
|
||||
let b_angles_eaten = (x / 2) as f64;
|
||||
// Connect to the point directly above (vertical connection).
|
||||
push_segment(current_index.checked_sub(columns as usize));
|
||||
|
||||
let offset_y_fraction = b_angles_eaten * tan_b - a_angles_eaten * tan_a;
|
||||
// Isometric grids additionally connect odd columns diagonally, splitting each cell into triangles.
|
||||
if grid_type == GridType::Isometric && x % 2 == 1 {
|
||||
// Connect to the point diagonally up-right (if not at the right edge).
|
||||
push_segment(current_index.checked_sub(columns as usize - 1).filter(|_| x + 1 < columns));
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
// Connect to the point diagonally up-left.
|
||||
push_segment(current_index.checked_sub(columns as usize + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -397,39 +374,56 @@ fn grid<T: GridSpacing>(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kurbo::ParamCurve;
|
||||
use vector_types::vector::misc::point_to_dvec2;
|
||||
|
||||
#[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());
|
||||
grid(&(), (), GridType::Isometric, 0., 5, 5, (0., 0.).into(), true);
|
||||
grid(&(), (), GridType::Isometric, 90., 5, 5, (90., 90.).into(), true);
|
||||
|
||||
// Works properly
|
||||
let grid = grid(&(), (), GridType::Isometric, 10., 5, 5, (30., 30.).into());
|
||||
let grid = grid(&(), (), GridType::Isometric, 10., 5, 5, (30., 30.).into(), true);
|
||||
assert_eq!(grid.point_domain.ids().len(), 5 * 5);
|
||||
assert_eq!(grid.segment_bezier_iter().count(), 4 * 5 + 4 * 9);
|
||||
for (_, bezier, _, _) in grid.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()
|
||||
);
|
||||
assert_eq!(grid.segment_iter().count(), 4 * 5 + 4 * 9);
|
||||
for (_, segment, _, _) in grid.segment_iter() {
|
||||
assert!(matches!(segment, kurbo::PathSeg::Line(_)));
|
||||
let span = point_to_dvec2(segment.start()) - point_to_dvec2(segment.end());
|
||||
assert!((span.length() - 10.).abs() < 1e-5, "Length of {} should be 10", span.length());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skew_isometric_grid_test() {
|
||||
let grid = grid(&(), (), GridType::Isometric, 10., 5, 5, (40., 30.).into());
|
||||
let grid = grid(&(), (), GridType::Isometric, 10., 5, 5, (40., 30.).into(), true);
|
||||
assert_eq!(grid.point_domain.ids().len(), 5 * 5);
|
||||
assert_eq!(grid.segment_bezier_iter().count(), 4 * 5 + 4 * 9);
|
||||
for (_, bezier, _, _) in grid.segment_bezier_iter() {
|
||||
assert_eq!(bezier.handles, subpath::BezierHandles::Linear);
|
||||
let vector = bezier.start - bezier.end;
|
||||
assert_eq!(grid.segment_iter().count(), 4 * 5 + 4 * 9);
|
||||
for (_, segment, _, _) in grid.segment_iter() {
|
||||
assert!(matches!(segment, kurbo::PathSeg::Line(_)));
|
||||
let vector = point_to_dvec2(segment.start()) - point_to_dvec2(segment.end());
|
||||
let angle = (vector.angle_to(DVec2::X).to_degrees() + 180.) % 180.;
|
||||
assert!([90f64, 150., 40.].into_iter().any(|target| (target - angle).abs() < 1e-10), "unexpected angle of {angle}")
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_disconnected_cells_test() {
|
||||
// A 3x3 rectangular grid has a 2x2 arrangement of cells, each its own closed quad subpath.
|
||||
let vector = grid(&(), (), GridType::Rectangular, 10., 3, 3, (30., 30.).into(), false);
|
||||
assert_eq!(vector.stroke_manipulator_groups().filter(|(_, closed)| *closed).count(), 4);
|
||||
assert_eq!(vector.point_domain.ids().len(), 4 * 4);
|
||||
assert_eq!(vector.segment_domain.ids().len(), 4 * 4);
|
||||
|
||||
// Each cell winds counter-clockwise (positive signed area), matching the shape generators.
|
||||
for (group, closed) in vector.stroke_manipulator_groups() {
|
||||
assert!(closed);
|
||||
let anchors: Vec<DVec2> = group.iter().map(|g| g.anchor).collect();
|
||||
let signed_area: f64 = (0..anchors.len()).map(|i| anchors[i].perp_dot(anchors[(i + 1) % anchors.len()])).sum::<f64>() / 2.;
|
||||
assert!(signed_area > 0., "grid cell should wind counter-clockwise");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qr_code_test() {
|
||||
let qr = qr_code(&(), (), "https://graphite.art".to_string(), false, 1., QRCodeErrorCorrectionLevel::Low, true);
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod generator_nodes;
|
||||
pub mod merge_qr_squares;
|
||||
pub mod vector_modification_nodes;
|
||||
mod vector_nodes;
|
||||
mod voronoi;
|
||||
|
||||
#[macro_use]
|
||||
extern crate log;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use glam::DVec2;
|
||||
use graphic_types::Vector;
|
||||
use std::collections::VecDeque;
|
||||
use vector_types::subpath;
|
||||
use vector_types::vector::VectorExt;
|
||||
use vector_types::vector::algorithms::shapes;
|
||||
|
||||
pub fn merge_qr_squares(qr_code: &qrcodegen::QrCode) -> Vector {
|
||||
let mut vector = Vector::default();
|
||||
@@ -106,7 +107,7 @@ pub fn merge_qr_squares(qr_code: &qrcodegen::QrCode) -> Vector {
|
||||
}
|
||||
|
||||
if !simplified.is_empty() {
|
||||
vector.append_subpath(subpath::Subpath::from_anchors(simplified, true), false);
|
||||
vector.append_bezpath(shapes::polyline_bezpath(simplified, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use core_types::attribute::{Attr, EditorLayerPath, RemoveAttr, Transform as TransformAttr};
|
||||
use core_types::gpoll::{GraphError, Interrupt};
|
||||
use core_types::transform::BakeTransform;
|
||||
use core_types::uuid::NodeId;
|
||||
use core_types::{Ctx, ExtractIndex, InjectIndex};
|
||||
use glam::DAffine2;
|
||||
use glam::{DAffine2, DVec2};
|
||||
use graphic_types::Vector;
|
||||
use vector_types::markers::EditorClickTarget;
|
||||
use vector_types::vector::VectorModification;
|
||||
@@ -20,6 +21,10 @@ fn path_modify<'e>(
|
||||
let mut element = element;
|
||||
if ctx.index() == 0 {
|
||||
modification.apply(&mut element);
|
||||
|
||||
// Users draw subpaths in arbitrary winding directions, so normalize them here rather than
|
||||
// letting the drawn direction decide fill insideness downstream
|
||||
element.normalize_winding_directions();
|
||||
}
|
||||
|
||||
// Set the path to the encapsulating subgraph (drop our own trailing entry from `node_path`),
|
||||
@@ -38,14 +43,14 @@ fn path_modify<'e>(
|
||||
Ok((element, Attr(parked.as_slice()), RemoveAttr::new()))
|
||||
}
|
||||
|
||||
/// Applies the vector path's local transformation to its geometry and resets the transform to the identity.
|
||||
/// Bakes the content's transform attribute into its underlying value, resetting the attribute to the identity.
|
||||
#[node_macro::node(category("Vector"))]
|
||||
fn apply_transform(_ctx: impl Ctx, (mut vector, transform): (Vector, Attr<TransformAttr>)) -> (Vector, Attr<TransformAttr>) {
|
||||
fn bake_transform<T: BakeTransform + Clone + Default + Send + Sync + 'static>(
|
||||
_ctx: impl Ctx,
|
||||
#[implementations(Vector, DAffine2, DVec2)] (mut content, transform): (T, Attr<TransformAttr>),
|
||||
) -> (T, Attr<TransformAttr>) {
|
||||
let transform: DAffine2 = *transform;
|
||||
for (_, point) in vector.point_domain.positions_mut() {
|
||||
*point = transform.transform_point2(*point);
|
||||
}
|
||||
vector.segment_domain.transform(transform);
|
||||
content.bake_transform(&transform);
|
||||
|
||||
(vector, Attr(DAffine2::IDENTITY))
|
||||
(content, Attr(DAffine2::IDENTITY))
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
472
node-graph/nodes/vector/src/voronoi.rs
Normal file
472
node-graph/nodes/vector/src/voronoi.rs
Normal file
@@ -0,0 +1,472 @@
|
||||
//! Geometry for the Voronoi and Delaunay nodes.
|
||||
//!
|
||||
//! Both diagrams are derived from a single Delaunay triangulation (computed by the `delaunator` crate). The Voronoi
|
||||
//! diagram is the geometric dual of that triangulation: each Voronoi vertex is the circumcenter of a Delaunay triangle,
|
||||
//! and each Voronoi edge connects the circumcenters of two triangles that share a Delaunay edge.
|
||||
//!
|
||||
//! Each function here reduces a diagram to a set of closed polygons (one per Delaunay triangle or per Voronoi cell). The
|
||||
//! nodes then assemble those polygons into vector geometry, either as separate filled subpaths or as a shared mesh of
|
||||
//! welded points and segments. Voronoi cells around the convex hull are unbounded, so they are clipped to the convex hull
|
||||
//! of the input sites, which also bounds the whole diagram to a finite region.
|
||||
|
||||
use delaunator::{EMPTY, Point, triangulate};
|
||||
use glam::DVec2;
|
||||
|
||||
/// Computes the Delaunay triangulation of `sites`, returning each triangle as a triple of indices into `sites`.
|
||||
///
|
||||
/// Returns an empty vector when there are fewer than three points or they are all colinear (no triangle exists).
|
||||
pub fn delaunay_triangles(sites: &[DVec2]) -> Vec<[usize; 3]> {
|
||||
let points: Vec<Point> = sites.iter().map(|p| Point { x: p.x, y: p.y }).collect();
|
||||
let triangulation = triangulate(&points);
|
||||
triangulation.triangles.chunks_exact(3).map(|t| [t[0], t[1], t[2]]).collect()
|
||||
}
|
||||
|
||||
/// Computes the Voronoi cell of every site, each clipped to the convex hull of `sites`.
|
||||
///
|
||||
/// Returns one closed polygon per site that produces a non-empty cell (degenerate or fully-clipped cells are omitted,
|
||||
/// so the result may be shorter than `sites`). Returns an empty vector when no triangulation exists (fewer than three points or all colinear).
|
||||
pub fn voronoi_cells(sites: &[DVec2]) -> Vec<Vec<DVec2>> {
|
||||
voronoi_cells_per_site(sites).0.into_iter().flatten().collect()
|
||||
}
|
||||
|
||||
/// Applies Lloyd's relaxation: each step moves every interior site to the centroid of its Voronoi cell, yielding a more
|
||||
/// even (centroidal) point distribution. A fractional `iterations` runs the whole-number steps and then blends each site
|
||||
/// partway toward the result of one more step, so the relaxation can be animated smoothly. Returns the sites unchanged when
|
||||
/// `iterations` is 0 or no diagram can be formed.
|
||||
///
|
||||
/// The convex-hull (perimeter) sites are pinned so the point cloud's outline is preserved. Otherwise, clipping the
|
||||
/// unbounded perimeter cells would drag those sites around (inward for the convex hull, or outward into the corners of a
|
||||
/// fixed bounding box), distorting the shape over successive iterations.
|
||||
pub fn relax_sites(sites: &[DVec2], iterations: f64) -> Vec<DVec2> {
|
||||
const MAX_STEPS_FOR_SAFETY: f64 = 1000.;
|
||||
let iterations = iterations.clamp(0., MAX_STEPS_FOR_SAFETY);
|
||||
let whole_steps = iterations.floor();
|
||||
let fraction = iterations - whole_steps;
|
||||
|
||||
let mut current = sites.to_vec();
|
||||
for _ in 0..whole_steps as u32 {
|
||||
current = relax_once(¤t);
|
||||
}
|
||||
|
||||
// Blend each site partway toward one further step for the fractional remainder.
|
||||
if fraction > 0. {
|
||||
let next = relax_once(¤t);
|
||||
for (point, target) in current.iter_mut().zip(next) {
|
||||
*point = point.lerp(target, fraction);
|
||||
}
|
||||
}
|
||||
|
||||
current
|
||||
}
|
||||
|
||||
/// Performs a single Lloyd relaxation step: moves every interior site to its Voronoi cell centroid,
|
||||
/// leaving the pinned convex-hull (perimeter) sites in place.
|
||||
fn relax_once(sites: &[DVec2]) -> Vec<DVec2> {
|
||||
let (cells, is_hull) = voronoi_cells_per_site(sites);
|
||||
let mut relaxed = sites.to_vec();
|
||||
for ((site, cell), on_hull) in relaxed.iter_mut().zip(cells).zip(is_hull) {
|
||||
if on_hull {
|
||||
continue;
|
||||
}
|
||||
if let Some(centroid) = cell.as_deref().and_then(polygon_centroid) {
|
||||
*site = centroid;
|
||||
}
|
||||
}
|
||||
relaxed
|
||||
}
|
||||
|
||||
/// The area-weighted centroid of a simple polygon, or `None` if it has fewer than three vertices or zero area.
|
||||
fn polygon_centroid(polygon: &[DVec2]) -> Option<DVec2> {
|
||||
if polygon.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let mut double_area = 0.;
|
||||
let mut weighted = DVec2::ZERO;
|
||||
for i in 0..polygon.len() {
|
||||
let a = polygon[i];
|
||||
let b = polygon[(i + 1) % polygon.len()];
|
||||
let cross = a.perp_dot(b);
|
||||
double_area += cross;
|
||||
weighted += (a + b) * cross;
|
||||
}
|
||||
(double_area.abs() >= f64::EPSILON).then(|| weighted / (3. * double_area))
|
||||
}
|
||||
|
||||
/// Computes each site's clipped Voronoi cell, aligned with `sites` (index `i` is the cell of `sites[i]`), together with a
|
||||
/// per-site flag marking the convex-hull (perimeter) sites. A cell is `None` when the site has no incident triangle
|
||||
/// (e.g. a coincident duplicate) or its cell vanishes after clipping.
|
||||
fn voronoi_cells_per_site(sites: &[DVec2]) -> (Vec<Option<Vec<DVec2>>>, Vec<bool>) {
|
||||
let points: Vec<Point> = sites.iter().map(|p| Point { x: p.x, y: p.y }).collect();
|
||||
let triangulation = triangulate(&points);
|
||||
if triangulation.triangles.is_empty() {
|
||||
return (vec![None; sites.len()], vec![false; sites.len()]);
|
||||
}
|
||||
|
||||
let triangles = &triangulation.triangles;
|
||||
let halfedges = &triangulation.halfedges;
|
||||
let hull_indices = &triangulation.hull;
|
||||
|
||||
// Mark which sites lie on the convex hull (the diagram's perimeter).
|
||||
let mut is_hull = vec![false; sites.len()];
|
||||
for &index in hull_indices {
|
||||
is_hull[index] = true;
|
||||
}
|
||||
|
||||
// One Voronoi vertex per Delaunay triangle.
|
||||
let circumcenters: Vec<DVec2> = triangles.chunks_exact(3).map(|t| circumcenter(sites[t[0]], sites[t[1]], sites[t[2]])).collect();
|
||||
|
||||
// The convex hull polygon, which clips the diagram to a finite region.
|
||||
let hull: Vec<DVec2> = hull_indices.iter().map(|&i| sites[i]).collect();
|
||||
|
||||
// `inedges[p]` is a half-edge ending at site `p`, preferring a hull half-edge so a hull cell's walk starts on the boundary.
|
||||
let mut inedges = vec![EMPTY; sites.len()];
|
||||
for edge in 0..triangles.len() {
|
||||
let endpoint = triangles[next_halfedge(edge)];
|
||||
if halfedges[edge] == EMPTY || inedges[endpoint] == EMPTY {
|
||||
inedges[endpoint] = edge;
|
||||
}
|
||||
}
|
||||
|
||||
// Outward ray directions for the two hull edges meeting at each hull site, used to project its unbounded cell outward.
|
||||
// Both are zero for interior sites.
|
||||
let mut ray_in = vec![DVec2::ZERO; sites.len()];
|
||||
let mut ray_out = vec![DVec2::ZERO; sites.len()];
|
||||
if let Some(&last) = hull_indices.last() {
|
||||
let mut previous = last;
|
||||
for ¤t in hull_indices {
|
||||
let p0 = sites[previous];
|
||||
let p1 = sites[current];
|
||||
// Perpendicular to the hull edge `previous -> current`, pointing away from the hull interior.
|
||||
let perpendicular = DVec2::new(p0.y - p1.y, p1.x - p0.x);
|
||||
ray_out[previous] = perpendicular;
|
||||
ray_in[current] = perpendicular;
|
||||
previous = current;
|
||||
}
|
||||
}
|
||||
|
||||
// Length to extend unbounded cell rays so they reach past the hull before clipping trims them back to it.
|
||||
let far = bounding_diagonal(&hull) * 10. + 1.;
|
||||
|
||||
let cells = (0..sites.len())
|
||||
.map(|site| {
|
||||
let mut polygon = cell_polygon(site, halfedges, &circumcenters, &inedges)?;
|
||||
|
||||
// A hull site's cell is unbounded; cap its open ends with far points along the outward hull-edge normals so the
|
||||
// convex-hull clip below closes it off at the boundary.
|
||||
let unbounded = ray_in[site] != DVec2::ZERO || ray_out[site] != DVec2::ZERO;
|
||||
if unbounded {
|
||||
if let Some(&first) = polygon.first() {
|
||||
polygon.insert(0, first + ray_in[site].normalize_or_zero() * far);
|
||||
}
|
||||
if let Some(&last) = polygon.last() {
|
||||
polygon.push(last + ray_out[site].normalize_or_zero() * far);
|
||||
}
|
||||
}
|
||||
|
||||
let clipped = clip_to_convex(&polygon, &hull);
|
||||
(clipped.len() >= 3).then_some(clipped)
|
||||
})
|
||||
.collect();
|
||||
|
||||
(cells, is_hull)
|
||||
}
|
||||
|
||||
/// The circumcenter of a triangle, computed relative to `a` for numerical stability. Falls back to the centroid for a
|
||||
/// degenerate (colinear) triangle.
|
||||
fn circumcenter(a: DVec2, b: DVec2, c: DVec2) -> DVec2 {
|
||||
let d = b - a;
|
||||
let e = c - a;
|
||||
let determinant = d.x * e.y - d.y * e.x;
|
||||
if determinant.abs() < f64::EPSILON {
|
||||
return (a + b + c) / 3.;
|
||||
}
|
||||
let factor = 0.5 / determinant;
|
||||
let bl = d.length_squared();
|
||||
let cl = e.length_squared();
|
||||
DVec2::new(a.x + (e.y * bl - d.y * cl) * factor, a.y + (d.x * cl - e.x * bl) * factor)
|
||||
}
|
||||
|
||||
/// Walks the Delaunay triangles incident to `site` and collects their circumcenters in order, forming the site's
|
||||
/// Voronoi cell polygon. The polygon is closed for interior sites and open (a fan ending at the hull) for hull sites.
|
||||
/// Returns `None` for a site with no incident triangle (e.g. a coincident duplicate point).
|
||||
fn cell_polygon(site: usize, halfedges: &[usize], circumcenters: &[DVec2], inedges: &[usize]) -> Option<Vec<DVec2>> {
|
||||
let start = inedges[site];
|
||||
if start == EMPTY {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut polygon = Vec::new();
|
||||
let mut edge = start;
|
||||
loop {
|
||||
polygon.push(circumcenters[edge / 3]);
|
||||
edge = halfedges[next_halfedge(edge)];
|
||||
if edge == EMPTY || edge == start {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Some(polygon)
|
||||
}
|
||||
|
||||
/// The next half-edge within the same triangle (triangles store three consecutive half-edges).
|
||||
fn next_halfedge(edge: usize) -> usize {
|
||||
if edge % 3 == 2 { edge - 2 } else { edge + 1 }
|
||||
}
|
||||
|
||||
/// Clips `subject` to the convex polygon `clip` using the Sutherland–Hodgman algorithm. The clip polygon may wind either way.
|
||||
/// (The subject doesn't need to be convex.) Returns the clipped polygon (empty if it lies entirely outside the clip region).
|
||||
fn clip_to_convex(subject: &[DVec2], clip: &[DVec2]) -> Vec<DVec2> {
|
||||
if clip.len() < 3 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Normalize the clip polygon to counter-clockwise so "inside" is consistently to the left of each directed edge.
|
||||
let mut clip = clip.to_vec();
|
||||
if signed_area(&clip) < 0. {
|
||||
clip.reverse();
|
||||
}
|
||||
|
||||
let mut output = subject.to_vec();
|
||||
for i in 0..clip.len() {
|
||||
if output.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
let edge_start = clip[i];
|
||||
let edge_end = clip[(i + 1) % clip.len()];
|
||||
let edge = edge_end - edge_start;
|
||||
let inside = |p: DVec2| edge.x * (p.y - edge_start.y) - edge.y * (p.x - edge_start.x) >= 0.;
|
||||
|
||||
let input = std::mem::take(&mut output);
|
||||
for j in 0..input.len() {
|
||||
let current = input[j];
|
||||
let previous = input[(j + input.len() - 1) % input.len()];
|
||||
let current_inside = inside(current);
|
||||
let previous_inside = inside(previous);
|
||||
|
||||
if current_inside {
|
||||
if !previous_inside && let Some(crossing) = line_intersection(previous, current, edge_start, edge_end) {
|
||||
output.push(crossing);
|
||||
}
|
||||
output.push(current);
|
||||
} else if previous_inside && let Some(crossing) = line_intersection(previous, current, edge_start, edge_end) {
|
||||
output.push(crossing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
/// The signed area of a polygon (positive for counter-clockwise winding).
|
||||
fn signed_area(polygon: &[DVec2]) -> f64 {
|
||||
let mut area = 0.;
|
||||
for i in 0..polygon.len() {
|
||||
let a = polygon[i];
|
||||
let b = polygon[(i + 1) % polygon.len()];
|
||||
area += a.x * b.y - b.x * a.y;
|
||||
}
|
||||
area / 2.
|
||||
}
|
||||
|
||||
/// The intersection point of the segment `p1 -> p2` with the infinite line through `a` and `b`, or `None` if parallel.
|
||||
fn line_intersection(p1: DVec2, p2: DVec2, a: DVec2, b: DVec2) -> Option<DVec2> {
|
||||
let r = p2 - p1;
|
||||
let s = b - a;
|
||||
let denominator = r.x * s.y - r.y * s.x;
|
||||
if denominator.abs() < f64::EPSILON {
|
||||
return None;
|
||||
}
|
||||
let t = ((a.x - p1.x) * s.y - (a.y - p1.y) * s.x) / denominator;
|
||||
Some(p1 + r * t)
|
||||
}
|
||||
|
||||
/// The diagonal length of the axis-aligned bounding box of `points`.
|
||||
fn bounding_diagonal(points: &[DVec2]) -> f64 {
|
||||
let mut min = DVec2::splat(f64::MAX);
|
||||
let mut max = DVec2::splat(f64::MIN);
|
||||
for &p in points {
|
||||
min = min.min(p);
|
||||
max = max.max(p);
|
||||
}
|
||||
let diagonal = (max - min).length();
|
||||
if diagonal.is_finite() && diagonal > 0. { diagonal } else { 0. }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn square_with_center() -> Vec<DVec2> {
|
||||
vec![DVec2::new(0., 0.), DVec2::new(10., 0.), DVec2::new(10., 10.), DVec2::new(0., 10.), DVec2::new(5., 5.)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delaunay_triangles_wind_counter_clockwise() {
|
||||
// `delaunator` returns clockwise triangles, so `delaunay_triangles` keeps that order, but `voronoi_cells` are
|
||||
// counter-clockwise. This documents the raw orientation; the Delaunay node reverses it to match the cells.
|
||||
let sites = square_with_center();
|
||||
for t in delaunay_triangles(&sites) {
|
||||
let poly = [sites[t[0]], sites[t[1]], sites[t[2]]];
|
||||
assert!(signed_area(&poly) < 0., "delaunator triangles are expected to be clockwise");
|
||||
}
|
||||
for cell in voronoi_cells(&sites) {
|
||||
assert!(signed_area(&cell) > 0., "voronoi cells are expected to be counter-clockwise");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delaunay_triangulates_square() {
|
||||
let triangles = delaunay_triangles(&square_with_center());
|
||||
// Four corner-to-center triangles tessellate the square.
|
||||
assert_eq!(triangles.len(), 4);
|
||||
for triangle in triangles {
|
||||
for index in triangle {
|
||||
assert!(index < 5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delaunay_degenerate_inputs_produce_no_triangles() {
|
||||
assert!(delaunay_triangles(&[]).is_empty());
|
||||
assert!(delaunay_triangles(&[DVec2::new(1., 1.)]).is_empty());
|
||||
assert!(delaunay_triangles(&[DVec2::new(0., 0.), DVec2::new(1., 1.)]).is_empty());
|
||||
// Colinear points have no triangulation.
|
||||
let colinear = vec![DVec2::new(0., 0.), DVec2::new(1., 1.), DVec2::new(2., 2.)];
|
||||
assert!(delaunay_triangles(&colinear).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voronoi_cells_tile_the_hull() {
|
||||
// The clipped cells partition the convex hull, so their (counter-clockwise, positive) areas sum to the hull's area
|
||||
// (100 for the 10x10 square). If the outward projection direction were inverted, the boundary cells would collapse
|
||||
// inward and the total would fall well short of 100.
|
||||
let sites = square_with_center();
|
||||
let total: f64 = voronoi_cells(&sites).iter().map(|cell| signed_area(cell)).sum();
|
||||
assert!((total - 100.).abs() < 1e-6, "cells should tile the hull (area 100), got {total}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voronoi_cells_stay_within_the_hull() {
|
||||
let sites = square_with_center();
|
||||
let cells = voronoi_cells(&sites);
|
||||
assert!(!cells.is_empty());
|
||||
// Clipping to the hull keeps every vertex inside the input bounds (with a small tolerance for float error).
|
||||
for cell in &cells {
|
||||
assert!(cell.len() >= 3);
|
||||
for &vertex in cell {
|
||||
assert!(vertex.x >= -1e-6 && vertex.x <= 10. + 1e-6, "x out of bounds: {}", vertex.x);
|
||||
assert!(vertex.y >= -1e-6 && vertex.y <= 10. + 1e-6, "y out of bounds: {}", vertex.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relaxation_with_zero_iterations_is_identity() {
|
||||
let sites = square_with_center();
|
||||
assert_eq!(relax_sites(&sites, 0.), sites);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relaxation_moves_points_and_keeps_them_in_the_hull() {
|
||||
// Add a point clustered near the center; relaxation should redistribute the points without leaving the hull.
|
||||
let mut sites = square_with_center();
|
||||
sites.push(DVec2::new(5.5, 4.5));
|
||||
let relaxed = relax_sites(&sites, 3.);
|
||||
|
||||
assert_eq!(relaxed.len(), sites.len());
|
||||
assert_ne!(relaxed, sites, "relaxation should move the points");
|
||||
for &point in &relaxed {
|
||||
assert!(point.x >= -1e-6 && point.x <= 10. + 1e-6, "x out of bounds: {}", point.x);
|
||||
assert!(point.y >= -1e-6 && point.y <= 10. + 1e-6, "y out of bounds: {}", point.y);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relaxation_pins_the_convex_hull() {
|
||||
// The four corners form the convex hull and must stay fixed; the interior points must relax.
|
||||
let sites = vec![
|
||||
DVec2::new(0., 0.),
|
||||
DVec2::new(10., 0.),
|
||||
DVec2::new(10., 10.),
|
||||
DVec2::new(0., 10.),
|
||||
DVec2::new(3., 3.),
|
||||
DVec2::new(7., 4.),
|
||||
];
|
||||
let relaxed = relax_sites(&sites, 4.);
|
||||
|
||||
for i in 0..4 {
|
||||
assert_eq!(relaxed[i], sites[i], "convex hull point {i} should be pinned");
|
||||
}
|
||||
assert!(relaxed[4] != sites[4] || relaxed[5] != sites[5], "interior points should relax");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relaxation_interpolates_fractional_iterations() {
|
||||
let sites = vec![
|
||||
DVec2::new(0., 0.),
|
||||
DVec2::new(10., 0.),
|
||||
DVec2::new(10., 10.),
|
||||
DVec2::new(0., 10.),
|
||||
DVec2::new(3., 4.),
|
||||
DVec2::new(7., 6.),
|
||||
];
|
||||
|
||||
// A fractional count lands exactly midway between the two bracketing whole-step results, exercising both the
|
||||
// pure-fraction path (0.5) and the whole-steps-then-fraction path (2.5).
|
||||
for whole in [0., 2.] {
|
||||
let lower = relax_sites(&sites, whole);
|
||||
let upper = relax_sites(&sites, whole + 1.);
|
||||
let half = relax_sites(&sites, whole + 0.5);
|
||||
for i in 0..sites.len() {
|
||||
let expected = (lower[i] + upper[i]) / 2.;
|
||||
assert!((half[i] - expected).length() < 1e-9, "index {i} at {whole}.5: {half:?} vs {expected:?}", half = half[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relaxation_leaves_degenerate_input_unchanged() {
|
||||
// Fewer than three points cannot form a diagram, so relaxation is a no-op.
|
||||
let sites = vec![DVec2::new(0., 0.), DVec2::new(1., 1.)];
|
||||
assert_eq!(relax_sites(&sites, 5.), sites);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relaxation_clamps_extreme_iteration_counts() {
|
||||
let sites = vec![
|
||||
DVec2::new(0., 0.),
|
||||
DVec2::new(10., 0.),
|
||||
DVec2::new(10., 10.),
|
||||
DVec2::new(0., 10.),
|
||||
DVec2::new(3., 4.),
|
||||
DVec2::new(7., 6.),
|
||||
];
|
||||
// A huge or infinite count must clamp to the converged result rather than hang on a billions-long loop.
|
||||
let converged = relax_sites(&sites, 1000.);
|
||||
assert_eq!(relax_sites(&sites, 1e9), converged);
|
||||
assert_eq!(relax_sites(&sites, f64::INFINITY), converged);
|
||||
// NaN and negative counts resolve to zero steps, leaving the sites unchanged.
|
||||
assert_eq!(relax_sites(&sites, f64::NAN), sites);
|
||||
assert_eq!(relax_sites(&sites, -5.), sites);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voronoi_center_cell_is_bounded() {
|
||||
// A ring of points around a center yields a finite cell for the center site.
|
||||
let mut sites = vec![DVec2::new(0., 0.)];
|
||||
for i in 0..6 {
|
||||
let angle = i as f64 / 6. * std::f64::consts::TAU;
|
||||
sites.push(DVec2::new(angle.cos() * 10., angle.sin() * 10.));
|
||||
}
|
||||
let cells = voronoi_cells(&sites);
|
||||
assert!(!cells.is_empty());
|
||||
// Every cell is a finite polygon with no runaway coordinates.
|
||||
for cell in &cells {
|
||||
for &vertex in cell {
|
||||
assert!(vertex.length() < 100., "unbounded cell vertex: {vertex:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user