figcraft/crates/render/src/paint.rs

170 lines
7.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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)
}