Files
figcraft/crates/render/src/lib.rs
T

207 lines
7.9 KiB
Rust

//! The FigCraft renderer.
//!
//! [`render_view`] draws a page region at any zoom into a premultiplied RGBA pixmap (the canvas);
//! [`render_node`] rasterises one node for export at a scale. Everything is CPU (tiny-skia) so it
//! runs identically on desktop, in tests and in the browser.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod draw;
pub mod fx;
pub mod images;
pub mod paint;
pub use draw::{node_outline, subtree_outline, ts_path};
pub use tiny_skia::{FillRule, Pixmap};
use figcraft_color::Color;
use figcraft_doc::{Document, NodeId, NodeKind};
use figcraft_geom::{Affine, Rect};
/// Which part of a page to draw, and how big.
#[derive(Clone, Debug)]
pub struct View {
/// Page coordinate shown at the pixmap's top-left.
pub origin: (f64, f64),
/// Device pixels per page unit.
pub scale: f64,
pub width: u32,
pub height: u32,
}
impl View {
pub fn transform(&self) -> Affine {
Affine::scale(self.scale) * Affine::translate((-self.origin.0, -self.origin.1))
}
pub fn page_rect(&self) -> Rect {
let s = self.scale.max(1e-9);
Rect::new(self.origin.0, self.origin.1, self.origin.0 + f64::from(self.width) / s, self.origin.1 + f64::from(self.height) / s)
}
}
#[derive(Clone, Debug, Default)]
pub struct Options {
/// Fill the background (None = transparent, e.g. export without page colour).
pub background: Option<Color>,
pub outline_mode: bool,
/// Nodes to leave out (an editor drawing them itself).
pub skip: Vec<NodeId>,
}
fn new_pixmap(w: u32, h: u32) -> Option<Pixmap> {
let w = w.clamp(1, 16_384);
let h = h.clamp(1, 16_384);
Pixmap::new(w, h)
}
/// Render a page region, splitting it into horizontal bands rendered in parallel (desktop).
pub fn render_view(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Option<Pixmap> {
#[cfg(not(target_arch = "wasm32"))]
{
let threads = rayon::current_num_threads().clamp(1, 16) as u32;
let bands = threads.min(view.height / 96).max(1);
if bands > 1 && view.width.saturating_mul(view.height) > 300_000 {
use rayon::prelude::*;
let band_h = view.height.div_ceil(bands);
let parts: Vec<(u32, Option<Pixmap>)> = (0..bands)
.into_par_iter()
.map(|i| {
let y0 = i * band_h;
let h = band_h.min(view.height.saturating_sub(y0));
let s = view.scale.max(1e-9);
let v =
View { origin: (view.origin.0, view.origin.1 + f64::from(y0) / s), scale: view.scale, width: view.width, height: h.max(1) };
(y0, render_view_single(doc, page, &v, opts))
})
.collect();
let mut out = new_pixmap(view.width, view.height)?;
for (y0, part) in parts {
let Some(part) = part else { continue };
out.draw_pixmap(
0,
y0 as i32,
part.as_ref(),
&tiny_skia::PixmapPaint { blend_mode: tiny_skia::BlendMode::Source, ..Default::default() },
tiny_skia::Transform::identity(),
None,
);
}
return Some(out);
}
}
render_view_single(doc, page, view, opts)
}
/// Render a page region on the calling thread.
pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Option<Pixmap> {
let mut pix = new_pixmap(view.width, view.height)?;
let bg = opts.background.or_else(|| match doc.node(page).map(|p| &p.kind) {
Some(NodeKind::Page { background, .. }) => Some(*background),
_ => None,
});
if let Some(bg) = bg {
pix.fill(paint::ts_color(bg));
}
let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: opts.outline_mode, skip: &opts.skip };
let xf = view.transform();
let Some(p) = doc.node(page) else { return Some(pix) };
for c in &p.children {
draw::draw_node(&ctx, &mut pix, *c, xf, 0);
}
Some(pix)
}
/// Render several nodes (in z-order) into an existing pixmap through `view`.
pub fn render_nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap) {
let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: false, skip: &[] };
for id in ids {
let parent_xf = doc.node(*id).and_then(|n| n.parent).map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY);
draw::draw_node(&ctx, pix, *id, view.transform() * parent_xf, 0);
}
}
/// The page-space rectangle a node exports: a clipping frame's box (plus its own effects), else
/// everything it paints.
pub fn export_bounds(doc: &Document, id: NodeId) -> Option<Rect> {
let n = doc.node(id)?;
if n.kind.is_frame_like() && n.clip {
let reach = n.paint_reach();
return Some(figcraft_geom::transform_rect_bbox(doc.world_transform(id), n.local_rect().inflate(reach, reach)));
}
doc.world_render_bounds(id)
}
/// Rasterise one node at `scale` (export). Transparent background.
pub fn render_node(doc: &Document, id: NodeId, scale: f64) -> Option<Pixmap> {
let b = export_bounds(doc, id)?;
let scale = if scale.is_finite() && scale > 0.0 { scale.min(64.0) } else { 1.0 };
let w = (b.width() * scale - 1e-6).ceil().max(1.0);
let h = (b.height() * scale - 1e-6).ceil().max(1.0);
if w > 16_384.0 || h > 16_384.0 {
return None;
}
let view = View { origin: (b.x0, b.y0), scale, width: w as u32, height: h as u32 };
let mut pix = new_pixmap(view.width, view.height)?;
render_nodes_into(doc, &[id], &view, &mut pix);
Some(pix)
}
/// Un-premultiply into straight RGBA8 bytes.
pub fn to_rgba8(p: &Pixmap) -> Vec<u8> {
let mut out = Vec::with_capacity(p.data().len());
for px in p.data().as_chunks::<4>().0 {
let a = px.get(3).copied().unwrap_or(0);
if a == 0 {
out.extend_from_slice(&[0, 0, 0, 0]);
} else {
let un = |c: u8| ((u32::from(c) * 255 + u32::from(a) / 2) / u32::from(a)).min(255) as u8;
out.extend_from_slice(&[
un(px.first().copied().unwrap_or(0)),
un(px.get(1).copied().unwrap_or(0)),
un(px.get(2).copied().unwrap_or(0)),
a,
]);
}
}
out
}
/// Encode as PNG.
pub fn encode_png(p: &Pixmap) -> Result<Vec<u8>, String> {
let rgba = to_rgba8(p);
let mut out = Vec::new();
let enc = image::codecs::png::PngEncoder::new(&mut out);
image::ImageEncoder::write_image(enc, &rgba, p.width(), p.height(), image::ExtendedColorType::Rgba8).map_err(|e| e.to_string())?;
Ok(out)
}
/// Encode as JPEG over `background` (JPEG has no alpha).
pub fn encode_jpeg(p: &Pixmap, background: Color, quality: u8) -> Result<Vec<u8>, String> {
let rgba = to_rgba8(p);
let bg = background.to_rgba8();
let mut rgb = Vec::with_capacity(rgba.len() / 4 * 3);
for px in rgba.as_chunks::<4>().0 {
let a = u32::from(px.get(3).copied().unwrap_or(0));
for c in 0..3 {
let s = u32::from(px.get(c).copied().unwrap_or(0));
let b = u32::from(bg.get(c).copied().unwrap_or(255));
rgb.push(((s * a + b * (255 - a) + 127) / 255) as u8);
}
}
let mut out = Vec::new();
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100));
image::ImageEncoder::write_image(enc, &rgb, p.width(), p.height(), image::ExtendedColorType::Rgb8).map_err(|e| e.to_string())?;
Ok(out)
}
/// Encode as lossless WebP.
pub fn encode_webp(p: &Pixmap) -> Result<Vec<u8>, String> {
let rgba = to_rgba8(p);
let mut out = Vec::new();
let enc = image::codecs::webp::WebPEncoder::new_lossless(&mut out);
image::ImageEncoder::write_image(enc, &rgba, p.width(), p.height(), image::ExtendedColorType::Rgba8).map_err(|e| e.to_string())?;
Ok(out)
}
#[cfg(test)]
mod tests;