use std::sync::Arc;
use ezu_graph::{
schema_frag, take_input_ref, BuiltNode, Connection, CoordSpace, EvalCtx, EvalError, FactoryCtx,
FactoryError, In, InReader, Node, NodeFactory, PortKind, PortSpec, PortValue,
};
use hokusai::Brush;
use serde_json::Value;
use xxhash_rust::xxh3::Xxh3;
use crate::nodes::common::{
canvas_into_raster, core_tile, downcast_brush, downcast_features, empty_raster, make_canvas,
resolve_field, srgb_to_linear_rgba,
};
use crate::{paint_lines, LineStrokeStyle};
struct LineNode {
color: In<[f32; 4]>,
pressure_base: In<f64>,
pressure_jitter: In<f64>,
dtime: In<f64>,
radius_px: Option<In<f64>>,
opacity: Option<In<f64>>,
radius_stroke_curve: Option<Vec<(f32, f32)>>,
opacity_stroke_curve: Option<Vec<(f32, f32)>>,
hardness_stroke_curve: Option<Vec<(f32, f32)>>,
dtime_stroke_curve: Option<Vec<(f32, f32)>>,
ports: Vec<PortSpec>,
param_refs: Vec<String>,
}
impl Node for LineNode {
fn op_name(&self) -> &'static str {
"line"
}
fn inputs(&self) -> &[PortSpec] {
&self.ports
}
fn output(&self, _input_kinds: &[Option<PortKind>]) -> PortKind {
PortKind::Raster
}
fn coord_space(&self) -> CoordSpace {
CoordSpace::World
}
fn eval(
&self,
ctx: &EvalCtx<'_>,
inputs: &[Option<PortValue>],
) -> Result<PortValue, EvalError> {
let feats = downcast_features(
inputs[0]
.as_ref()
.ok_or_else(|| EvalError::MissingInput("features".into()))?,
)?;
let brush_arc = downcast_brush(
inputs[1]
.as_ref()
.ok_or_else(|| EvalError::MissingInput("brush".into()))?,
)?;
if !feats.has_lines() {
return Ok(empty_raster(ctx));
}
let mut canvas = make_canvas(ctx)?;
let mut brush: Brush = (*brush_arc).clone();
if let Some(r) = &self.radius_px {
let r = r.get(ctx, inputs)? as f32;
brush.get_mut(hokusai::BrushSetting::Radius).base_value = r.max(0.05).ln();
}
if let Some(o) = &self.opacity {
let o = o.get(ctx, inputs)? as f32;
brush.get_mut(hokusai::BrushSetting::Opaque).base_value = o.clamp(0.0, 1.0);
}
let lin = srgb_to_linear_rgba(self.color.get(ctx, inputs)?);
let style = LineStrokeStyle {
color: [lin[0], lin[1], lin[2]],
pressure_base: self.pressure_base.get(ctx, inputs)? as f32,
pressure_jitter: self.pressure_jitter.get(ctx, inputs)? as f32,
dtime: self.dtime.get(ctx, inputs)? as f32,
radius_stroke_curve: self.radius_stroke_curve.clone(),
opacity_stroke_curve: self.opacity_stroke_curve.clone(),
hardness_stroke_curve: self.hardness_stroke_curve.clone(),
dtime_stroke_curve: self.dtime_stroke_curve.clone(),
};
let lines: Vec<_> = feats.lines().cloned().collect();
paint_lines(
&mut canvas,
&lines,
feats.extent,
core_tile(ctx),
&brush,
&style,
);
Ok(PortValue::Raster(Arc::new(canvas_into_raster(canvas))))
}
fn param_hash(&self, h: &mut Xxh3) {
h.update(b"line");
self.color.param_hash(h);
self.pressure_base.param_hash(h);
self.pressure_jitter.param_hash(h);
self.dtime.param_hash(h);
if let Some(r) = &self.radius_px {
h.update(&[1]);
r.param_hash(h);
} else {
h.update(&[0]);
}
if let Some(o) = &self.opacity {
h.update(&[1]);
o.param_hash(h);
} else {
h.update(&[0]);
}
hash_curve(h, b"r", self.radius_stroke_curve.as_deref());
hash_curve(h, b"o", self.opacity_stroke_curve.as_deref());
hash_curve(h, b"h", self.hardness_stroke_curve.as_deref());
hash_curve(h, b"d", self.dtime_stroke_curve.as_deref());
}
fn param_refs(&self) -> Vec<String> {
self.param_refs.clone()
}
}
fn hash_curve(h: &mut Xxh3, tag: &[u8], curve: Option<&[(f32, f32)]>) {
h.update(tag);
match curve {
None => h.update(&[0]),
Some(pts) => {
h.update(&[1]);
h.update(&(pts.len() as u32).to_le_bytes());
for (x, y) in pts {
h.update(&x.to_le_bytes());
h.update(&y.to_le_bytes());
}
}
}
}
pub(super) struct LineFactory;
impl NodeFactory for LineFactory {
fn op_name(&self) -> &'static str {
"line"
}
fn build(
&self,
fields: &serde_json::Map<String, Value>,
ctx: &FactoryCtx<'_>,
) -> Result<BuiltNode, FactoryError> {
let features = take_input_ref(fields, "features")?;
let brush = take_input_ref(fields, "brush")?;
let mut r = InReader::new(fields, ctx, 2);
let color = r.color("color")?;
let pressure_base = r.number_or("pressure-base", 0.7)?;
let pressure_jitter = r.number_or("pressure-jitter", 0.2)?;
let dtime = r.number_or("dtime", 0.02)?;
let radius_px = if fields.contains_key("radius-px") {
Some(r.number("radius-px")?)
} else {
None
};
let opacity = if fields.contains_key("opacity") {
Some(r.number("opacity")?)
} else {
None
};
let parts = r.finish();
let radius_stroke_curve = read_stroke_curve(fields, "radius-stroke-curve", ctx)?;
let opacity_stroke_curve = read_stroke_curve(fields, "opacity-stroke-curve", ctx)?;
let hardness_stroke_curve = read_stroke_curve(fields, "hardness-stroke-curve", ctx)?;
let dtime_stroke_curve = read_stroke_curve(fields, "dtime-stroke-curve", ctx)?;
let mut ports = vec![
PortSpec {
name: "features",
accepts: &[PortKind::Features],
optional: false,
},
PortSpec {
name: "brush",
accepts: &[PortKind::Brush],
optional: false,
},
];
ports.extend(parts.ports);
let mut connections = vec![
Connection {
port: "features".into(),
src: features,
},
Connection {
port: "brush".into(),
src: brush,
},
];
connections.extend(parts.connections);
Ok(BuiltNode {
node: Box::new(LineNode {
color,
pressure_base,
pressure_jitter,
dtime,
radius_px,
opacity,
radius_stroke_curve,
opacity_stroke_curve,
hardness_stroke_curve,
dtime_stroke_curve,
ports,
param_refs: parts.param_refs,
}),
connections,
})
}
fn schema(&self) -> Value {
let curve_shape = serde_json::json!({
"type": "array",
"items": {
"type": "array",
"items": { "type": "number" },
"minItems": 2,
"maxItems": 2,
},
"minItems": 2,
});
let brush_curve = {
let mut v = curve_shape.clone();
v["description"] = Value::String(
"Piecewise-linear `[[t, y], ...]` driving a libmypaint `stroke` input on the brush. `t` is normalized stroke progress in [0, 1]; `y` is an offset added to the setting's base value. `radius` is log-space (y=-2.3 ≈ ×0.1, y=+0.69 ≈ ×2); `opaque` and `hardness` are linear."
.into(),
);
v
};
let dtime_curve = {
let mut v = curve_shape;
v["description"] = Value::String(
"Piecewise-linear `[[t, y], ...]` multiplier on `dtime`. `y` scales the per-vertex dtime — y=3 makes the brush linger 3× longer (slower hand), y=0.3 sweeps through 3× faster. Used with dynamics-driven brushes that respond to stroke speed."
.into(),
);
v
};
serde_json::json!({
"description": "Brush stroke along MVT polylines.",
"properties": {
"features": schema_frag::node_ref(),
"brush": schema_frag::node_ref(),
"color": schema_frag::color(),
"radius-px": schema_frag::px_number(),
"opacity": schema_frag::unit_number(),
"pressure-base": schema_frag::unit_number(),
"pressure-jitter": schema_frag::unit_number(),
"dtime": schema_frag::in_number(serde_json::json!({ "type": "number", "minimum": 0.0 })),
"radius-stroke-curve": brush_curve.clone(),
"opacity-stroke-curve": brush_curve.clone(),
"hardness-stroke-curve": brush_curve,
"dtime-stroke-curve": dtime_curve,
},
"required": ["features", "brush", "color"],
})
}
}
fn read_stroke_curve(
fields: &serde_json::Map<String, Value>,
name: &str,
ctx: &FactoryCtx<'_>,
) -> Result<Option<Vec<(f32, f32)>>, FactoryError> {
if !fields.contains_key(name) {
return Ok(None);
}
let v = resolve_field(fields, name, ctx)?;
let arr = v.as_array().ok_or_else(|| FactoryError::BadField {
field: name.into(),
msg: "expected array of [t, y] pairs".into(),
})?;
if arr.len() < 2 {
return Err(FactoryError::BadField {
field: name.into(),
msg: "stroke curve needs at least 2 points".into(),
});
}
let mut out = Vec::with_capacity(arr.len());
let mut prev_t: Option<f32> = None;
for (i, pt) in arr.iter().enumerate() {
let pair = pt.as_array().ok_or_else(|| FactoryError::BadField {
field: name.into(),
msg: format!("entry {i}: expected [t, y] pair"),
})?;
if pair.len() != 2 {
return Err(FactoryError::BadField {
field: name.into(),
msg: format!("entry {i}: expected exactly 2 numbers"),
});
}
let t = pair[0].as_f64().ok_or_else(|| FactoryError::BadField {
field: name.into(),
msg: format!("entry {i}: t must be number"),
})? as f32;
let y = pair[1].as_f64().ok_or_else(|| FactoryError::BadField {
field: name.into(),
msg: format!("entry {i}: y must be number"),
})? as f32;
if let Some(p) = prev_t {
if t < p {
return Err(FactoryError::BadField {
field: name.into(),
msg: format!("entry {i}: t must be non-decreasing"),
});
}
}
prev_t = Some(t);
out.push((t, y));
}
Ok(Some(out))
}
ezu_graph::submit_node!(LineFactory);