use crate::brush::eval::{BrushNodeEvaluator, EvalContext};
use crate::brush::node::BrushNodeRegistration;
use crate::brush::wgsl::{CompileWgslCtx, ExtentContribution, ExtentCtx, NodeWgsl};
use crate::brush::wire::{BrushWireType, ScalarValue};
use crate::gpu::params::ParamDef;
use crate::nodegraph::{NodeRegistration, PortDef, UnitType};
const ALGO_SINE: u32 = 0;
const ALGO_PERLIN: u32 = 1;
const ALGO_SUPERFORMULA: u32 = 2;
pub const TYPE_ID: &str = "shape";
pub fn register() -> BrushNodeRegistration {
BrushNodeRegistration {
pipelines: vec![],
evaluator: || Box::new(ShapeEvaluator),
lifecycle: crate::brush::node::Lifecycle::None,
node: NodeRegistration {
type_id: TYPE_ID,
category: "shape",
display_name: "Shape",
description: "Procedural brush-tip silhouette — disc, bumpy circle, or superformula.",
ports: vec![
PortDef::input("softness", BrushWireType::Scalar)
.with_range(0.0, 1.0, 0.5)
.with_natural_range(0.0, 1.0)
.with_label("Softness")
.with_unit(UnitType::Percent)
.with_icon("fa6-solid:feather")
.with_description("Edge softness (0% = hard, 100% = feathered)"),
PortDef::input("amplitude", BrushWireType::Scalar)
.with_range(0.0, 0.5, 0.0)
.with_natural_range(0.0, 0.5)
.with_label("Amplitude")
.with_unit(UnitType::Percent)
.with_visible_when("algorithm", [ALGO_SINE as i32, ALGO_PERLIN as i32])
.with_description("Bump amplitude as a fraction of the base radius."),
PortDef::input("frequency", BrushWireType::Scalar)
.with_range(1.0, 16.0, 6.0)
.with_natural_range(1.0, 16.0)
.with_step(1.0)
.with_label("Frequency")
.with_unit(UnitType::Raw)
.with_description(
"Sine: number of bumps (n). Perlin: base period in cells per revolution. \
Superformula: symmetry order m. Must be an integer — \
non-integer values would create a seam at θ = ±π where the \
shape fails to close.",
),
PortDef::input("rotation_input", BrushWireType::Scalar)
.with_range(-std::f32::consts::TAU, std::f32::consts::TAU, 0.0)
.with_label("Rotation Input")
.with_unit(UnitType::Degrees)
.with_description(
"Live rotation, added on top of Rotation. Wire pen direction here so the shape follows your stroke.",
),
PortDef::input("rotation", BrushWireType::Scalar)
.with_range(-std::f32::consts::TAU, std::f32::consts::TAU, 0.0)
.with_label("Rotation")
.with_unit(UnitType::Degrees)
.persist_in_thumbnail()
.with_description(
"Spin the shape around its centre. Wire a changing signal into Rotation Input instead if you want it to move as you draw.",
),
PortDef::input("persistence", BrushWireType::Scalar)
.with_range(0.0, 1.0, 0.5)
.with_natural_range(0.0, 1.0)
.with_label("Persistence")
.with_unit(UnitType::Percent)
.with_visible_when("algorithm", [ALGO_PERLIN as i32])
.with_description("Per-octave amplitude falloff. Higher = rougher edge."),
PortDef::input("seed", BrushWireType::Scalar)
.with_range(0.0, 1024.0, 0.0)
.with_natural_range(0.0, 1024.0)
.with_label("Seed")
.with_unit(UnitType::Raw)
.with_visible_when("algorithm", [ALGO_PERLIN as i32])
.with_description("RNG seed for the noise array."),
PortDef::input("octaves", BrushWireType::Scalar)
.with_range(1.0, 6.0, 3.0)
.with_natural_range(1.0, 6.0)
.with_label("Octaves")
.with_unit(UnitType::Raw)
.with_visible_when("algorithm", [ALGO_PERLIN as i32])
.with_description("Number of stacked frequencies."),
PortDef::input("n1", BrushWireType::Scalar)
.with_range(0.1, 16.0, 1.0)
.with_natural_range(0.1, 16.0)
.with_label("n1")
.with_unit(UnitType::Raw)
.with_visible_when("algorithm", [ALGO_SUPERFORMULA as i32])
.with_description("Overall fatness/sharpness."),
PortDef::input("n2", BrushWireType::Scalar)
.with_range(0.1, 16.0, 1.0)
.with_natural_range(0.1, 16.0)
.with_label("n2")
.with_unit(UnitType::Raw)
.with_visible_when("algorithm", [ALGO_SUPERFORMULA as i32])
.with_description("Shape of bump rise."),
PortDef::input("n3", BrushWireType::Scalar)
.with_range(0.1, 16.0, 1.0)
.with_natural_range(0.1, 16.0)
.with_label("n3")
.with_unit(UnitType::Raw)
.with_visible_when("algorithm", [ALGO_SUPERFORMULA as i32])
.with_description("Shape of bump fall."),
PortDef::output("mask", BrushWireType::Scalar)
.with_natural_range(0.0, 1.0)
.with_description("Per-fragment mask value (0..1) — the procedural shape's alpha at this fragment"),
],
params: &[ParamDef::Enum {
name: "algorithm",
options: &["Sine Harmonic", "Perlin Noise", "Superformula"],
default: 0,
}],
is_gpu: true,
is_terminal: false,
supports_erase: true,
},
}
}
fn superformula_r(theta: f32, frequency: f32, n1: f32, n2: f32, n3: f32) -> f32 {
let m_quarter = frequency * theta * 0.25;
let term_a = (m_quarter.cos().abs()).powf(n2);
let term_b = (m_quarter.sin().abs()).powf(n3);
let sum = term_a + term_b;
if sum <= 0.0 {
return 0.0;
}
sum.powf(-1.0 / n1)
}
pub struct ShapeEvaluator;
impl BrushNodeEvaluator for ShapeEvaluator {
fn evaluate_cpu(&self, _ctx: &EvalContext) -> Vec<(String, ScalarValue)> {
vec![]
}
fn compile_wgsl(&self, cctx: &CompileWgslCtx) -> Result<NodeWgsl, String> {
let mut wgsl = NodeWgsl::default();
if !cctx.consumed_outputs.contains("mask") {
return Ok(wgsl);
}
let algorithm = match cctx.params.first() {
Some(crate::gpu::params::ParamValue::Int(v)) => (*v as u32).min(2),
_ => 0,
};
let amplitude = cctx.input("amplitude").as_f32();
let frequency = cctx.input("frequency").as_f32();
let rotation = cctx.input("rotation").as_f32();
let rotation_input = cctx.input("rotation_input").as_f32();
let persistence = cctx.input("persistence").as_f32();
let seed = cctx.input("seed").as_f32();
let octaves = cctx.input("octaves").as_f32();
let n1 = cctx.input("n1").as_f32();
let n2 = cctx.input("n2").as_f32();
let n3 = cctx.input("n3").as_f32();
let softness = cctx.input("softness").as_f32();
let params_ident = cctx.ident("shape_params");
let shape_ident = cctx.ident("shape");
let body = format!(
" let {params_ident}: ShapeParams = ShapeParams(\n\
\x20 {algorithm}u,\n\
\x20 max(({amplitude}), 0.0),\n\
\x20 max(round(({frequency})), 1.0),\n\
\x20 ({rotation}) + ({rotation_input}),\n\
\x20 clamp(({persistence}), 0.0, 1.0),\n\
\x20 ({seed}),\n\
\x20 clamp(u32(round(({octaves}))), 1u, 6u),\n\
\x20 max(({n1}), 0.05),\n\
\x20 max(({n2}), 0.05),\n\
\x20 max(({n3}), 0.05),\n\
\x20 );\n\
\x20 let {shape_ident}_r_at: f32 = shape_r_theta({params_ident}, theta);\n\
\x20 let {shape_ident}_band: f32 = max(clamp(({softness}), 0.0, 1.0), 0.004);\n\
\x20 let {shape_ident}: f32 = 1.0 - smoothstep({shape_ident}_r_at - {shape_ident}_band, {shape_ident}_r_at, local_dist);\n",
);
wgsl.body = body;
wgsl.outputs.insert("mask".into(), shape_ident);
Ok(wgsl)
}
fn extent(&self, ctx: &ExtentCtx) -> ExtentContribution {
let algorithm = match ctx.params.first() {
Some(crate::gpu::params::ParamValue::Int(v)) => (*v as u32).min(2),
_ => 0,
};
let factor = match algorithm {
ALGO_SINE | ALGO_PERLIN => 1.0 + ctx.port_max_value("amplitude").max(0.0),
ALGO_SUPERFORMULA => {
let frequency = ctx.port_max_value("frequency").max(1.0).round();
let n1 = ctx.port_max_value("n1").max(0.05);
let n2 = ctx.port_max_value("n2").max(0.05);
let n3 = ctx.port_max_value("n3").max(0.05);
let mut max_r: f32 = 0.0;
for i in 0..32 {
let theta = -std::f32::consts::PI + (i as f32) * std::f32::consts::TAU / 32.0;
max_r = max_r.max(superformula_r(theta, frequency, n1, n2, n3));
}
max_r.max(1.0)
}
_ => 1.0,
};
ExtentContribution::Multiply(factor)
}
}