use crate::brush::eval::{BrushNodeEvaluator, EvalContext};
use crate::brush::node::BrushNodeRegistration;
use crate::brush::wgsl::{CompileWgslCtx, NodeWgsl};
use crate::brush::wire::BrushWireType;
use crate::brush::wire::ScalarValue;
use crate::nodegraph::{NodeRegistration, PortDef};
pub const TYPE_ID: &str = "levels";
pub fn register() -> BrushNodeRegistration {
BrushNodeRegistration::compute(
NodeRegistration {
type_id: TYPE_ID,
category: "modulate",
display_name: "Levels",
description: "Adjusts a value's low end, high end, and midpoint — the same idea as the image-editing Levels filter.",
ports: vec![
PortDef::input("input", BrushWireType::Scalar)
.with_natural_range(0.0, 1.0)
.with_description("Input value (0\u{2013}1) to window"),
PortDef::input("in_low", BrushWireType::Scalar)
.with_range(0.0, 1.0, 0.0)
.with_natural_range(0.0, 1.0)
.with_description("Input black point — anything at or below this clips to 0"),
PortDef::input("in_high", BrushWireType::Scalar)
.with_range(0.0, 1.0, 1.0)
.with_natural_range(0.0, 1.0)
.with_description(
"Input white point — anything at or above this saturates to 1",
),
PortDef::output("output", BrushWireType::Scalar)
.with_natural_range(0.0, 1.0)
.with_description("Clamp((input - in_low) / (in_high - in_low), 0, 1)"),
],
params: &[],
is_gpu: false,
is_terminal: false,
supports_erase: true,
},
|| Box::new(LevelsEvaluator),
)
}
pub struct LevelsEvaluator;
fn levels_apply(input: f32, in_low: f32, in_high: f32) -> f32 {
let denom = (in_high - in_low).max(1e-6);
((input - in_low) / denom).clamp(0.0, 1.0)
}
impl BrushNodeEvaluator for LevelsEvaluator {
fn evaluate_cpu(&self, ctx: &EvalContext) -> Vec<(String, ScalarValue)> {
let input = ctx.input_f32("input");
let in_low = ctx.input_f32("in_low");
let in_high = ctx.input_f32("in_high");
vec![(
"output".into(),
ScalarValue::Scalar(levels_apply(input, in_low, in_high)),
)]
}
fn compile_wgsl(&self, cctx: &CompileWgslCtx) -> Result<NodeWgsl, String> {
let mut wgsl = NodeWgsl::default();
if !cctx.consumed_outputs.contains("output") {
return Ok(wgsl);
}
let input = cctx.input("input").as_f32();
let in_low = cctx.input("in_low").as_f32();
let in_high = cctx.input("in_high").as_f32();
let expr = format!(
"clamp((({input}) - ({in_low})) / max(({in_high}) - ({in_low}), 1e-6), 0.0, 1.0)"
);
wgsl.outputs.insert("output".into(), expr);
Ok(wgsl)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identity_defaults_pass_through() {
for &x in &[0.0_f32, 0.25, 0.5, 0.75, 1.0] {
let y = levels_apply(x, 0.0, 1.0);
assert!((y - x).abs() < 1e-6, "x={x} → y={y}");
}
}
#[test]
fn clamps_out_of_range_inputs() {
assert_eq!(levels_apply(-0.5, 0.0, 1.0), 0.0);
assert_eq!(levels_apply(1.5, 0.0, 1.0), 1.0);
}
#[test]
fn windowed_input_remaps_to_full_range() {
assert!((levels_apply(0.25, 0.25, 0.75) - 0.0).abs() < 1e-6);
assert!((levels_apply(0.5, 0.25, 0.75) - 0.5).abs() < 1e-6);
assert!((levels_apply(0.75, 0.25, 0.75) - 1.0).abs() < 1e-6);
}
#[test]
fn threshold_mode_in_low_equals_in_high() {
let pivot = 0.4;
assert_eq!(levels_apply(0.1, pivot, pivot), 0.0);
assert_eq!(levels_apply(0.9, pivot, pivot), 1.0);
}
#[test]
fn degenerate_inverted_range_clamps_to_zero() {
assert_eq!(levels_apply(0.5, 0.8, 0.2), 0.0);
}
}