170 lines
7.9 KiB
Rust
170 lines
7.9 KiB
Rust
//! Paints → tiny-skia shaders, in a node's local space (width × height box).
|
||
|
||
use std::sync::Arc;
|
||
|
||
use figcraft_color::{BlendMode, Color, Gradient, GradientKind, ImagePaint, ImageScaleMode, Paint, PaintKind};
|
||
use figcraft_doc::Document;
|
||
use tiny_skia::{FilterQuality, GradientStop, Pixmap, Point, Shader, SpreadMode, Transform};
|
||
|
||
pub fn ts_color(c: Color) -> tiny_skia::Color {
|
||
tiny_skia::Color::from_rgba(c.r, c.g, c.b, c.a).unwrap_or(tiny_skia::Color::BLACK)
|
||
}
|
||
|
||
pub fn ts_blend(b: BlendMode) -> tiny_skia::BlendMode {
|
||
use tiny_skia::BlendMode as T;
|
||
match b {
|
||
BlendMode::PassThrough | BlendMode::Normal => T::SourceOver,
|
||
BlendMode::Darken => T::Darken,
|
||
BlendMode::Multiply => T::Multiply,
|
||
// Plus darker: approximate with multiply-ish darken.
|
||
BlendMode::PlusDarker => T::Multiply,
|
||
BlendMode::ColorBurn => T::ColorBurn,
|
||
BlendMode::Lighten => T::Lighten,
|
||
BlendMode::Screen => T::Screen,
|
||
BlendMode::PlusLighter => T::Plus,
|
||
BlendMode::ColorDodge => T::ColorDodge,
|
||
BlendMode::Overlay => T::Overlay,
|
||
BlendMode::SoftLight => T::SoftLight,
|
||
BlendMode::HardLight => T::HardLight,
|
||
BlendMode::Difference => T::Difference,
|
||
BlendMode::Exclusion => T::Exclusion,
|
||
BlendMode::Hue => T::Hue,
|
||
BlendMode::Saturation => T::Saturation,
|
||
BlendMode::Color => T::Color,
|
||
BlendMode::Luminosity => T::Luminosity,
|
||
}
|
||
}
|
||
|
||
fn stops(g: &Gradient, opacity: f32) -> Vec<GradientStop> {
|
||
g.sorted_stops().into_iter().map(|s| GradientStop::new(s.position, ts_color(s.color.with_alpha(s.color.a * opacity)))).collect()
|
||
}
|
||
|
||
/// The unit-box → local mapping for a gradient's handles: maps (0,0)→from, (1,0)→to,
|
||
/// (0,1)→width (relative to from), in a w×h box.
|
||
fn handle_frame(g: &Gradient, w: f64, h: f64) -> Transform {
|
||
let f = (g.from.0 * w, g.from.1 * h);
|
||
let t = (g.to.0 * w, g.to.1 * h);
|
||
let k = (g.width.0 * w, g.width.1 * h);
|
||
Transform::from_row((t.0 - f.0) as f32, (t.1 - f.1) as f32, (k.0 - f.0) as f32, (k.1 - f.1) as f32, f.0 as f32, f.1 as f32)
|
||
}
|
||
|
||
/// What a paint needs beyond a shader: an owned pixmap backing an image/diamond pattern.
|
||
pub enum Prepared {
|
||
Shader(Shader<'static>),
|
||
Pattern { pixmap: Arc<Pixmap>, mode: SpreadMode, quality: FilterQuality, opacity: f32, transform: Transform },
|
||
}
|
||
|
||
impl Prepared {
|
||
pub fn shader(&self) -> Shader<'_> {
|
||
match self {
|
||
Prepared::Shader(s) => s.clone(),
|
||
Prepared::Pattern { pixmap, mode, quality, opacity, transform } => {
|
||
tiny_skia::Pattern::new(pixmap.as_ref().as_ref(), *mode, *quality, *opacity, *transform)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn diamond_pixmap(g: &Gradient, opacity: f32) -> Option<Arc<Pixmap>> {
|
||
const N: u32 = 256;
|
||
let mut p = Pixmap::new(N, N)?;
|
||
let half = f64::from(N) / 2.0;
|
||
for (i, px) in p.data_mut().as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
||
let x = ((i as u32 % N) as f64 + 0.5 - half) / half;
|
||
let y = ((i as u32 / N) as f64 + 0.5 - half) / half;
|
||
let t = (x.abs() + y.abs()) as f32;
|
||
let c = g.sample(t);
|
||
let a = c.a * opacity;
|
||
let q = |v: f32| (v * a * 255.0).round().clamp(0.0, 255.0) as u8;
|
||
px.copy_from_slice(&[q(c.r), q(c.g), q(c.b), (a * 255.0).round().clamp(0.0, 255.0) as u8]);
|
||
}
|
||
Some(Arc::new(p))
|
||
}
|
||
|
||
/// Build the shader for `paint` on a `w`×`h` local box. Returns `None` when the paint draws
|
||
/// nothing (invisible, fully transparent, missing image).
|
||
pub fn prepare(paint: &Paint, w: f64, h: f64, doc: &Document) -> Option<Prepared> {
|
||
if !paint.visible || paint.opacity <= 0.0 {
|
||
return None;
|
||
}
|
||
let op = paint.opacity.clamp(0.0, 1.0);
|
||
match &paint.kind {
|
||
PaintKind::Solid { color } => {
|
||
let c = color.with_alpha(color.a * op);
|
||
(c.a > 0.0).then(|| Prepared::Shader(Shader::SolidColor(ts_color(c))))
|
||
}
|
||
PaintKind::Gradient(g) => {
|
||
let frame = handle_frame(g, w.max(1e-6), h.max(1e-6));
|
||
let st = stops(g, op);
|
||
let shader = match g.kind {
|
||
GradientKind::Linear => {
|
||
tiny_skia::LinearGradient::new(Point::from_xy(0.0, 0.0), Point::from_xy(1.0, 0.0), st, SpreadMode::Pad, frame)
|
||
}
|
||
GradientKind::Radial => {
|
||
tiny_skia::RadialGradient::new(Point::from_xy(0.0, 0.0), 0.0, Point::from_xy(0.0, 0.0), 1.0, st, SpreadMode::Pad, frame)
|
||
}
|
||
GradientKind::Angular => tiny_skia::SweepGradient::new(Point::from_xy(0.0, 0.0), 0.0, 360.0, st, SpreadMode::Pad, frame),
|
||
GradientKind::Diamond => {
|
||
let pm = diamond_pixmap(g, op)?;
|
||
// Pattern pixel space (0..256) → unit square (-1..1) → handle frame.
|
||
let to_unit = Transform::from_row(2.0 / 256.0, 0.0, 0.0, 2.0 / 256.0, -1.0, -1.0);
|
||
return Some(Prepared::Pattern {
|
||
pixmap: pm,
|
||
mode: SpreadMode::Pad,
|
||
quality: FilterQuality::Bilinear,
|
||
opacity: 1.0,
|
||
transform: to_unit.post_concat(frame),
|
||
});
|
||
}
|
||
};
|
||
shader.map(Prepared::Shader)
|
||
}
|
||
PaintKind::Image(img) => {
|
||
let data = doc.images.get(&img.image);
|
||
let pm = crate::images::get(&img.image, data, &img.filters)?;
|
||
let transform = image_transform(img, f64::from(pm.width()), f64::from(pm.height()), w, h);
|
||
if img.scale_mode == ImageScaleMode::Tile {
|
||
return Some(Prepared::Pattern { pixmap: pm, mode: SpreadMode::Repeat, quality: FilterQuality::Bicubic, opacity: op, transform });
|
||
}
|
||
// Fit/Fill/Crop: a transparent 1-px border so Pad spreads transparency, not edge pixels.
|
||
let padded = crate::images::padded(&img.image, &img.filters, &pm)?;
|
||
let transform = Transform::from_translate(-1.0, -1.0).post_concat(transform);
|
||
Some(Prepared::Pattern { pixmap: padded, mode: SpreadMode::Pad, quality: FilterQuality::Bicubic, opacity: op, transform })
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Mapping from image pixels to the node's local box for each scale mode.
|
||
pub fn image_transform(img: &ImagePaint, iw: f64, ih: f64, w: f64, h: f64) -> Transform {
|
||
let iw = iw.max(1.0);
|
||
let ih = ih.max(1.0);
|
||
let rot = img.rotation.rem_euclid(4);
|
||
let (rw, rh) = if rot % 2 == 1 { (ih, iw) } else { (iw, ih) };
|
||
// Rotation about the image centre, then translate so the rotated image's box starts at 0.
|
||
let rot_t = Transform::from_translate(-(iw / 2.0) as f32, -(ih / 2.0) as f32)
|
||
.post_rotate(90.0 * rot as f32)
|
||
.post_translate((rw / 2.0) as f32, (rh / 2.0) as f32);
|
||
let fitted = match img.scale_mode {
|
||
ImageScaleMode::Fill => {
|
||
let s = (w / rw).max(h / rh);
|
||
Transform::from_scale(s as f32, s as f32).post_translate(((w - rw * s) / 2.0) as f32, ((h - rh * s) / 2.0) as f32)
|
||
}
|
||
ImageScaleMode::Fit => {
|
||
let s = (w / rw).min(h / rh);
|
||
Transform::from_scale(s as f32, s as f32).post_translate(((w - rw * s) / 2.0) as f32, ((h - rh * s) / 2.0) as f32)
|
||
}
|
||
ImageScaleMode::Tile => {
|
||
let s = if img.scale.is_finite() && img.scale > 0.0 { img.scale } else { 1.0 };
|
||
Transform::from_scale(s as f32, s as f32)
|
||
}
|
||
ImageScaleMode::Crop => {
|
||
// crop = unit box → image uv (a, b, c, d, e, f). Inverse maps uv → unit box.
|
||
let m = img.crop.unwrap_or([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
|
||
let uv_to_unit =
|
||
Transform::from_row(m[0] as f32, m[1] as f32, m[2] as f32, m[3] as f32, m[4] as f32, m[5] as f32).invert().unwrap_or_default();
|
||
Transform::from_scale((1.0 / rw) as f32, (1.0 / rh) as f32).post_concat(uv_to_unit).post_scale(w as f32, h as f32)
|
||
}
|
||
};
|
||
rot_t.post_concat(fitted)
|
||
}
|