mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 20:36:55 -06:00
**A paint and effects behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule (fill stacking and opacity, paint vs layer opacity, every blend mode per paint and per layer, pass-through and Normal containers, the four gradient kinds and their handles, stops, image scale modes, rotation and adjustments, the colour models and hex entry, the document colour profile, drop and inner shadows, showing shadows behind transparent areas, layer and background blurs, effects on groups, effect order, what exports include) is a table-driven case with a one-line derivation; where the documentation is silent the case says so and pins today's behaviour. Scenes are small and synthetic; pixel cases use tolerances (blurs are checked against the normal distribution with sigma = radius / 2).
**Fixes (13 render cases and 8 command/export cases fail on the old code)**
- **Pass-through and Normal containers**: a group or frame set to Normal now blends its children among themselves first (it never isolated), and a **clipping frame no longer isolates** its children: they were drawn on a fresh layer, so a Multiply child inside a frame (frames clip by default) met neither the frame's fill nor what is behind it. The clipped layer now starts as a copy of what lies under it and goes back in its place through the clip (a memcpy for rectangular clips, so no slower); backdrop effects see the right composite, also for a clipping frame inside a faded group. Same in exports: SVG gives Normal groups `isolation:isolate` and puts a clipping frame's clip on what blends instead of a clipped `<g>` (a clipped group is a stacking context, which isolates); PDF wraps Normal groups in an isolated group (`Node::isolates_children`, shared).
- **Plus darker** (linear burn) is drawn by its formula on the canvas and in pixel exports for paints, layers, shadows and noise (it was multiply); tiny-skia lacks the mode, so `fx::plus_darker` composites it and `paint::composite` routes every layer, shadow and noise composite through it.
- **Shadows hidden behind the layer**: a drop shadow that doesn't show behind transparent areas is now hidden behind what the layer covers, translucent and 0% fills included (it was cut out by the layer's alpha, so it showed through a 50% fill anyway); groups and layers whose pixels a layer blur or texture moves still hide it behind their pixels. One `shadow::Caster::new` serves the canvas, PNG and the PDF's shadow rasters. **SVG** never cut shadows out: its filters now composite them `out` of an image of the layer's outline (or `SourceAlpha` for groups), and the filter region reaches as far as the effects (a fixed 200% box cut off a small layer's long shadow).
- **Fill images cover the box to its edges**: an upscaled Fill (or covering Crop) image faded to transparent over a source pixel at the box's edge.
- **"Normal" meant pass-through** when named: `BlendMode::from_name("normal")` found pass-through first (its CSS keyword is also `normal`), so `node.set {blend: "normal"}` and the blend menu's Normal set pass-through. Labels now win, and the menu sends labels.
**Added**
- A blend mode menu for each paint in the colour picker (no pass-through for paints), sharing `right::blend_menu` with the Design panel's layer menu; strings already translated.
- Inspect (`node.get`, MCP) reports a solid paint's `blend` when it isn't normal.
213 lines
10 KiB
Rust
213 lines
10 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 has no tiny-skia mode: `composite` draws it; elsewhere the nearest one.
|
||
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,
|
||
}
|
||
}
|
||
|
||
/// Draw `src` (top-left at `at`) onto `dst` at `opacity`, blended by `blend`.
|
||
pub fn composite(dst: &mut Pixmap, at: (i32, i32), src: &Pixmap, opacity: f32, blend: BlendMode) {
|
||
if blend == BlendMode::PlusDarker {
|
||
crate::fx::plus_darker(dst, at, src, opacity);
|
||
} else {
|
||
let paint = tiny_skia::PixmapPaint { opacity, blend_mode: ts_blend(blend), ..Default::default() };
|
||
dst.draw_pixmap(at.0, at.1, src.as_ref(), &paint, Transform::identity(), None);
|
||
}
|
||
}
|
||
|
||
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 shown at `scale` device pixels per unit
|
||
/// (the resolution pattern tiles are drawn at). Returns `None` when the paint draws nothing
|
||
/// (invisible, fully transparent, missing image or pattern source).
|
||
pub fn prepare(paint: &Paint, w: f64, h: f64, scale: f64, doc: &Document) -> Option<Prepared> {
|
||
prepare_at(paint, w, h, scale, doc, 0.0)
|
||
}
|
||
|
||
/// [`prepare`] `time` seconds into animated GIFs (0: their first frame).
|
||
pub fn prepare_at(paint: &Paint, w: f64, h: f64, scale: f64, doc: &Document, time: f64) -> 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 moving = crate::images::frame_at(&img.image, data, &img.filters, doc.color_profile, time);
|
||
let pm = match &moving {
|
||
Some(f) => f.clone(),
|
||
None => crate::images::get(&img.image, data, &img.filters, doc.color_profile)?,
|
||
};
|
||
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 });
|
||
}
|
||
// An image covering the whole box fills it to its edges (a transparent border would
|
||
// fade them where the image is scaled up).
|
||
if covers_box(img, f64::from(pm.width()), f64::from(pm.height()), w, h) {
|
||
return Some(Prepared::Pattern { pixmap: pm, mode: SpreadMode::Pad, quality: FilterQuality::Bicubic, opacity: op, transform });
|
||
}
|
||
// Fit/Crop: a transparent 1-px border so Pad spreads transparency, not edge pixels.
|
||
// A GIF's later frames each show once per loop: not worth caching padded.
|
||
let padded = match moving {
|
||
Some(_) => Arc::new(crate::images::pad(&pm)?),
|
||
None => crate::images::padded(&img.image, &img.filters, doc.color_profile, &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 })
|
||
}
|
||
PaintKind::Pattern(pt) => crate::pattern::prepare(doc, pt, w, h, scale, time, op),
|
||
}
|
||
}
|
||
|
||
/// Whether an image paint covers its whole `w` × `h` box: Fill always, Crop when its frame does.
|
||
fn covers_box(img: &ImagePaint, iw: f64, ih: f64, w: f64, h: f64) -> bool {
|
||
match img.scale_mode {
|
||
ImageScaleMode::Fill => true,
|
||
ImageScaleMode::Crop => {
|
||
let m = img.effective_crop(iw, ih, w, h);
|
||
let (x0, y0, x1, y1) = ImagePaint::crop_rect(m);
|
||
m[1] == 0.0 && m[2] == 0.0 && x0.min(x1) <= 0.0 && y0.min(y1) <= 0.0 && x0.max(x1) >= 1.0 && y0.max(y1) >= 1.0
|
||
}
|
||
ImageScaleMode::Fit | ImageScaleMode::Tile => false,
|
||
}
|
||
}
|
||
|
||
/// 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.effective_crop(iw, ih, w, h);
|
||
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)
|
||
}
|