vectorcraft/crates/render/src/lib.rs

2064 lines
92 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.

//! VectorCraft renderer: document → premultiplied RGBA pixels.
//!
//! The backend is `vello_cpu` (SIMD, sparse strips). Callers give a *view transform* mapping
//! document points to output pixels; the renderer culls by bounds, evaluates appearance stacks
//! (multiple fills/strokes, opacity, blend modes, stroke alignment, dashes), clip groups,
//! gradients, images and text (via `vectorcraft-text` glyph outlines).
#![forbid(unsafe_code)]
mod brush_fx;
pub mod encode;
mod freeform;
mod fx;
mod group;
mod images;
mod ink;
mod live;
mod lru;
mod paint;
mod pattern;
pub mod placed_document;
pub mod proof;
use std::collections::HashMap;
use std::sync::Arc;
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextObject, TraceView};
use vectorcraft_geom::{Affine, BezPath, FillRule, Rect, Shape};
use vello_cpu::kurbo;
use vello_cpu::peniko::{self, BlendMode, Compose, Mix};
use vello_cpu::{Pixmap, RenderContext, Resources};
use group::Composite;
use ink::Ink;
use lru::FrameCache;
pub use brush_fx::instance_art;
pub use effects::stroke::width_outline;
pub use images::{IMAGE_CACHE_MAX, image_cache_bytes, set_image_cache_budget};
pub use live::expand_live;
pub use pattern::render_pattern_swatch;
pub use vectorcraft_effects as effects;
pub use vello_cpu;
/// Bounds of everything `n` paints, as the renderer culls it: its members, strokes, brush art and
/// geometry effects, and the shadows and glows of it and its members.
pub fn painted_bounds(n: &Node) -> Option<Rect> {
brush_fx::cull_bounds(n)
}
/// Largest raster an export may ask [`Renderer::render_region`] for, per side. The CPU rasteriser
/// addresses at most `u16::MAX` pixels per side (and panics at that edge).
pub const MAX_RASTER_SIDE: u32 = 32_768;
/// Largest raster an export may ask for in total (16384², 1 GiB of RGBA pixels).
pub const MAX_RASTER_PIXELS: u64 = 1 << 28;
/// Pixel size of `region` rendered at `scale` (as [`Renderer::render_region`] rounds it), or an error
/// when it exceeds [`MAX_RASTER_SIDE`] / [`MAX_RASTER_PIXELS`]. Raster exports check this first,
/// because the renderer can only clamp (wrong picture) or fail (abort) at those sizes.
pub fn raster_size(region: Rect, scale: f64) -> Result<(u32, u32), String> {
if !(scale.is_finite() && scale > 0.0) {
return Err(format!("invalid scale {scale}"));
}
let (w, h) = region_pixels(region, scale);
if !(w <= MAX_RASTER_SIDE && h <= MAX_RASTER_SIDE && u64::from(w) * u64::from(h) <= MAX_RASTER_PIXELS) {
return Err(format!(
"the image would be {w} × {h} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {} megapixels: lower the scale or resolution",
MAX_RASTER_PIXELS / 1_000_000
));
}
Ok((w, h))
}
/// Rendering options.
#[derive(Clone, Debug)]
pub struct RenderOptions {
/// Outline (wireframe) view: 1 px black paths, no paint.
pub outline: bool,
/// Pasteboard colour behind everything (premultiplied RGBA8); `None` = transparent.
pub background: Option<[u8; 4]>,
/// Draw artboards as white rectangles (screen view). Export sets this to false.
pub artboards: bool,
/// Objects not to draw (e.g. the object being edited by a live drag preview).
pub hidden: Vec<NodeId>,
/// Draw template layers dimmed (50%).
pub dim_templates: bool,
/// Soft proof / separations preview (see [`proof`]).
pub proof: Option<proof::ProofSetup>,
/// Overprint Preview (see [`proof`] for what overprints).
pub overprint_preview: bool,
/// Trim View: clip the artwork to the artboards (nothing on the pasteboard is drawn).
pub trim: bool,
/// Leave template layers and guides out (exports and thumbnails: they're aids to drawing, not
/// artwork).
pub skip_templates: bool,
/// Pattern editing mode's tile edge and swatch bounds colour (Object → Pattern → Tile Edge
/// Color), RGB.
pub tile_edge: [u8; 3],
/// View Opacity Mask (Alt-click the mask thumbnail): instead of the artwork, show this object's
/// opacity mask alone as its coverage in greyscale (white = opaque, black = transparent).
pub mask_view: Option<NodeId>,
/// Screen view: highlight substituted fonts and glyphs as Document Setup asks.
pub highlight_substitutions: bool,
/// Edge smoothing (raster export option).
pub anti_alias: AntiAlias,
/// Screen view: placed documents draw from cached bitmaps made in the background (see
/// [`placed_document`]); off, they draw exactly, read when needed.
pub progressive_placed: bool,
/// Screen view: Image Trace objects draw as their View asks (outlines, the source image…);
/// off, they draw their tracing result, as exports and printing do.
pub trace_views: bool,
/// Images are sampled smoothly when scaled or rotated; off, each pixel takes its nearest image
/// pixel (Pixel Preview with File Handling › Display Bitmaps as Anti-aliased Images off).
pub smooth_images: bool,
/// Screen view in isolation mode: the isolated group or layer. Everything around it draws
/// dimmed ([`ISOLATION_DIM`]).
pub isolated: Option<NodeId>,
/// Composite in floating point (exports): translucent art over opaque art comes out exactly
/// opaque, where 8-bit compositing leaves alpha 254 on some anti-aliased edges (#787). Off,
/// the faster 8-bit pipeline (the canvas).
pub precise: bool,
/// Exports: Art Optimized edges are supersampled ([`supersample_factor`]): the art is drawn
/// with hard edges at several times the size and averaged down, so shapes that meet edge to
/// edge leave no seam of the background between them (#983). Off, edges are smoothed shape by
/// shape (the canvas).
pub supersample: bool,
/// Screen views: an image drawn at half its size or smaller samples a copy shrunk by halves
/// (box-filtered from the full image, kept with the decoded images), so the full image needn't
/// stay in memory or be sampled. Off, images always sample their full pixels (exports).
pub image_mips: bool,
}
/// The opacity of the art around an isolated group or layer.
pub const ISOLATION_DIM: f32 = 0.5;
/// How edges are rasterized (raster export option).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AntiAlias {
/// Hard edges: a pixel is painted when a path, text or image covers at least half of it.
/// Raster effects (blur, shadows, glows) and pattern tiles stay smooth.
None,
/// Smooth edges everywhere.
#[default]
Art,
/// Smooth edges with type snapped to the pixel grid (see `TextLayout::snap_to_pixels`):
/// crisper small text.
Type,
}
impl AntiAlias {
pub const ALL: [Self; 3] = [Self::None, Self::Art, Self::Type];
/// The id used in command params (`none`, `art`, `type`).
pub fn id(self) -> &'static str {
match self {
Self::None => "none",
Self::Art => "art",
Self::Type => "type",
}
}
/// The mode an id names (any case).
pub fn from_id(id: &str) -> Option<Self> {
Self::ALL.into_iter().find(|a| a.id().eq_ignore_ascii_case(id))
}
/// The name shown in options dialogs.
pub fn label(self) -> &'static str {
match self {
Self::None => "None",
Self::Art => "Art Optimized",
Self::Type => "Type Optimized",
}
}
/// vello's aliasing threshold: paint a pixel only above half coverage when edges are hard.
fn threshold(self) -> Option<u8> {
(self == Self::None).then_some(127)
}
}
impl Default for RenderOptions {
fn default() -> Self {
Self {
outline: false,
background: None,
artboards: false,
hidden: vec![],
dim_templates: true,
proof: None,
overprint_preview: false,
trim: false,
skip_templates: false,
tile_edge: vectorcraft_doc::LAYER_COLORS[0].1,
mask_view: None,
highlight_substitutions: false,
anti_alias: AntiAlias::Art,
progressive_placed: false,
trace_views: false,
smooth_images: true,
isolated: None,
precise: false,
supersample: false,
image_mips: false,
}
}
}
/// The most pixels a supersampled render draws (the frame and its compositing buffers hold a few
/// times this many bytes).
const MAX_SUPERSAMPLED_PIXELS: u64 = 40_000_000;
/// How many times wider and taller a `w`×`h` render is drawn when supersampled: 4, or less where
/// that would pass [`MAX_SUPERSAMPLED_PIXELS`] or the renderer's 65,535-pixel side; `None` when not
/// even twice fits (the edges are then smoothed shape by shape).
pub fn supersample_factor(w: u32, h: u32) -> Option<u32> {
(2..=4u32).rev().find(|k| {
let (sw, sh) = (u64::from(w) * u64::from(*k), u64::from(h) * u64::from(*k));
sw <= u64::from(u16::MAX) && sh <= u64::from(u16::MAX) && sw * sh <= MAX_SUPERSAMPLED_PIXELS
})
}
/// Average each `k`×`k` block of `src` (`w`×`h` pixels of 4 premultiplied bytes) into one pixel.
fn downsample(src: &[u8], w: usize, h: usize, k: usize) -> Vec<u8> {
let (ow, oh) = (w / k.max(1), h / k.max(1));
let n = (k * k).max(1) as u32;
let mut out = vec![0u8; ow * oh * 4];
for (o, px) in out.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let (ox, oy) = (o % ow.max(1), o / ow.max(1));
let mut acc = [0u32; 4];
for y in oy * k..(oy + 1) * k {
let row = y * w + ox * k;
for p in src.get(row * 4..(row + k) * 4).unwrap_or_default().as_chunks::<4>().0 {
for (a, v) in acc.iter_mut().zip(p) {
*a += u32::from(*v);
}
}
}
for (d, a) in px.iter_mut().zip(acc) {
*d = ((a + n / 2) / n).min(255) as u8;
}
}
out
}
/// A rendered image (premultiplied RGBA8, row-major).
pub struct Rendered {
pub width: u32,
pub height: u32,
pub pixels: Vec<u8>,
}
impl Rendered {
/// Un-premultiplied RGBA8 copy.
pub fn to_straight(&self) -> Vec<u8> {
let mut out = self.pixels.clone();
for px in out.as_chunks_mut::<4>().0 {
let a = px[3] as u32;
if a != 0 && a != 255 {
for c in &mut px[..3] {
*c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
}
}
}
out
}
/// Encode as PNG without metadata (thumbnails, screenshots, embedded rasters; exports use
/// [`encode`]).
pub fn to_png(&self) -> Result<Vec<u8>, String> {
let mut buf = Vec::new();
let img = image::RgbaImage::from_raw(self.width, self.height, self.to_straight())
.ok_or("PNG encoding failed: pixel buffer doesn't match the image size")?;
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).map_err(|e| format!("PNG encoding failed: {e}"))?;
Ok(buf)
}
/// Encode as JPEG (flattened on white) at `quality` 1..=100.
pub fn to_jpeg(&self, quality: u8) -> Result<Vec<u8>, String> {
encode::jpeg(self, quality, None)
}
/// Encode as lossless WebP.
pub fn to_webp(&self) -> Result<Vec<u8>, String> {
encode::webp(self)
}
/// Straight-alpha RGBA at (x, y).
pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] {
let i = ((y * self.width + x) * 4) as usize;
let p = &self.pixels[i..i + 4];
let a = p[3] as u32;
if a == 0 {
return [0, 0, 0, 0];
}
let un = |c: u8| ((c as u32 * 255 + a / 2) / a).min(255) as u8;
[un(p[0]), un(p[1]), un(p[2]), p[3]]
}
}
/// Hasher for pointer-keyed caches: a multiply-xorshift (SipHash showed up in frame profiles).
#[derive(Default, Clone, Copy)]
struct PtrHasher(u64);
impl std::hash::Hasher for PtrHasher {
fn write(&mut self, bytes: &[u8]) {
for b in bytes {
self.0 = (self.0 ^ *b as u64).wrapping_mul(0x0100_0000_01b3);
}
}
fn write_u64(&mut self, n: u64) {
let x = (self.0 ^ n).wrapping_mul(0x9e37_79b9_7f4a_7c15);
self.0 = x ^ (x >> 29);
}
fn write_usize(&mut self, n: usize) {
self.write_u64(n as u64);
}
fn finish(&self) -> u64 {
self.0
}
}
type PtrMap<K, V> = HashMap<K, V, std::hash::BuildHasherDefault<PtrHasher>>;
/// A stroke expanded to a fill outline: (owning node, outline, last frame used).
struct StrokeEntry {
node: Arc<Node>,
outline: Arc<BezPath>,
stamp: u64,
}
/// The slices of a stroke whose gradient runs along or across it, each with its paint and bounds.
type Slices = Arc<Vec<(BezPath, vectorcraft_color::Paint, Rect)>>;
/// [`Slices`] cached for a stroke: (owning node, slices, last frame used).
struct SliceEntry {
node: Arc<Node>,
slices: Slices,
stamp: u64,
}
/// Cached per-node geometry, keyed by `Arc` identity. Structural sharing means an unchanged node
/// keeps its allocation across edits, so a pointer match (with the Arc kept alive here so the
/// address can't be reused) is an exact cache hit — no invalidation logic needed.
struct GeomEntry {
node: Arc<Node>,
bounds: Option<Rect>,
path: Option<Arc<BezPath>>,
stamp: u64,
}
/// A clipping path (kept alive so its address can't be reused) and the region it clips to.
type ClipEntry = (Arc<Node>, Option<Arc<(BezPath, FillRule)>>);
/// A clipping path (kept alive) and what it paints: its fills and its strokes ([`Node::clip_paint`]).
type ClipPaintEntry = (Arc<Node>, Option<Arc<Node>>, Option<Arc<Node>>);
/// Reusable renderer (keeps the render context, geometry and glyph caches between frames; decoded
/// images are shared by all renderers, see [`set_image_cache_budget`]).
pub struct Renderer {
/// Glyph geometry per text object, keyed by `Arc` identity like `geom` (see [`Self::text_geom_of`]).
texts: FrameCache<usize, (Arc<Node>, Arc<TextGeom>)>,
/// The [`vectorcraft_text::FontDb::generation`] `texts` was laid out with: type lays out
/// again when fonts are added or rescanned.
text_fonts: u64,
/// Clip regions per clipping path (see [`Self::clip_of`]).
clips: FrameCache<usize, ClipEntry>,
/// Context reused when rendering single-threaded (`threads == 0`).
ctx_st: Option<RenderContext>,
/// Worker threads for the multithreaded rasterizer (0 = single-threaded).
pub threads: u16,
geom: PtrMap<usize, GeomEntry>,
/// Expanded stroke outlines keyed by (stroke layer address, width bits, tolerance level):
/// panning re-renders reuse them instead of re-expanding every stroke.
strokes: PtrMap<(usize, u64, i32), StrokeEntry>,
/// The node being drawn on the plain-path fast path (owner of cached stroke outlines).
cur: Option<Arc<Node>>,
/// The node `draw_shape_fx` may cache the live-effect geometry of (the one `draw_arc` is drawing).
fx_owner: Option<Arc<Node>>,
/// Live-effect geometry per node ([`fx::FxGeom`]), keyed by `Arc` identity like `geom`.
fx_geom: PtrMap<usize, fx::FxGeom>,
stamp: u64,
/// Opacity folded into paint alpha for the leaf being drawn (avoids a compositing layer).
alpha: f32,
ctx: Option<RenderContext>,
resources: Resources,
/// Statistics of the last frame.
pub stats: FrameStats,
/// Brush art per brushed stroke.
brushes: brush_fx::BrushCache,
/// Evaluated blends/envelopes and tessellated meshes.
live: live::LiveCache,
/// Blurred, tinted drop shadow / outer glow rasters per object and effect (see `fx`).
shadows: PtrMap<(usize, usize, Ink), fx::ShadowEntry>,
/// Objects' content run through Photoshop-style effects, per object and content slot (see
/// `fx`): kept only while drawn.
pixel_fx: PtrMap<(usize, usize, Ink), fx::PixelEntry>,
/// Whether the group being drawn is a knockout group (what its neutral children inherit).
knockout: bool,
/// Address of the knockout-group element being drawn as its knockout shape: at full object
/// opacity and without its opacity mask (see [`Self::draw_knockout`]); 0 = none.
shape_of: usize,
/// The art drawn for objects with effects that apply through it (see `fx::has_object_fx`).
fx_arts: FrameCache<usize, (Arc<Node>, Arc<Node>)>,
/// Layers open around the drawing point in the current context: a non-isolated group can copy
/// its backdrop only when there are none (see [`Self::group`]).
nested: u32,
/// Copy of the backdrop of the non-isolated group being drawn offscreen, in its context's
/// pixels: the elements of a non-isolated knockout group composite against it.
backdrop: Option<Arc<Pixmap>>,
/// [`Node::blends_through`] per container, keyed like `geom`.
blends: PtrMap<usize, (Arc<Node>, bool, u64)>,
/// Clip paths pushed on the current context (path, rule, transform): starting the context
/// again from a picture of it pushes them again (see [`Self::group`]).
clip_paths: Vec<(BezPath, FillRule, Affine)>,
/// What clipping paths paint, per clipping path (see [`Self::clip_paint_of`]).
clip_paints: FrameCache<usize, ClipPaintEntry>,
/// Gradients along or across strokes, keyed like `strokes` (see [`Self::fill_path_gradient`]).
stroke_slices: PtrMap<(usize, i32), SliceEntry>,
/// What the image keys colour adjustments give their recoloured images name: the image
/// recoloured and the adjustment, with its hash (see [`Self::adjusted_image`]).
adjusts: HashMap<String, (String, u64, vectorcraft_effects::ColorMap)>,
/// Layer Options → Dim Images to, of the layer being drawn (screen views only): images show
/// faded to this opacity over white.
dim_images: Option<f32>,
/// Inline graphics being drawn inside inline graphics (a symbol whose art holds text showing
/// it): drawing stops at [`MAX_INLINE_DEPTH`].
inline_depth: u32,
/// How the frame being drawn is rasterized ([`RenderOptions::precise`]): its offscreen groups,
/// masks and patterns too.
raster: vello_cpu::RasterizerSettings,
/// [`RenderOptions::isolated`]'s layers and groups, from the top layer down to it, for the
/// frame (`stamp`) and document they were found in.
isolation: Option<(u64, usize, NodeId, Vec<NodeId>)>,
/// Drawing inside the isolated container or a dimmed object: nothing further dims.
isolation_settled: bool,
}
/// How deep inline graphics nest (text in a symbol shown inline in text…) before they draw nothing.
const MAX_INLINE_DEPTH: u32 = 4;
/// Set once a missing inline symbol has been logged (it would log every frame otherwise).
static MISSING_INLINE_LOGGED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
#[derive(Clone, Copy, Debug, Default)]
pub struct FrameStats {
pub drawn: usize,
pub culled: usize,
pub micros: u64,
}
impl Default for Renderer {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Copy)]
struct Frame<'a> {
/// Drawing into a multithreaded context: vello filter layers must be rendered offscreen.
mt: bool,
doc: &'a Document,
view: Affine,
/// Visible region in document coordinates (for culling).
visible: Rect,
/// Size of one output pixel in document units.
px: f64,
opts: &'a RenderOptions,
/// What colours paint as: screen colours, or one ink plane of a CMYK document (see [`ink`]).
ink: Ink,
}
impl Frame<'_> {
/// Text space → device pixels when type is snapped to the pixel grid (Type anti-aliasing).
fn text_snap(&self, t: &TextObject) -> Option<Affine> {
(self.opts.anti_alias == AntiAlias::Type).then(|| self.view * t.xf)
}
/// A render context for drawing this frame's art offscreen (on the calling thread), with its
/// edge smoothing: hard edges must not depend on where the art is drawn.
fn offscreen_context(&self, w: u16, h: u16) -> RenderContext {
let mut ctx = single_threaded_context(w, h);
ctx.set_aliasing_threshold(self.opts.anti_alias.threshold());
ctx
}
}
impl Renderer {
pub fn new() -> Self {
Self {
texts: FrameCache::new(lru::TEXT_BUDGET, 1024),
text_fonts: 0,
clips: FrameCache::new(lru::CLIP_BUDGET, 256),
ctx_st: None,
threads: default_threads(),
geom: PtrMap::default(),
strokes: PtrMap::default(),
cur: None,
fx_owner: None,
fx_geom: PtrMap::default(),
stamp: 0,
alpha: 1.0,
ctx: None,
resources: Resources::new(),
stats: FrameStats::default(),
brushes: Default::default(),
shadows: PtrMap::default(),
pixel_fx: PtrMap::default(),
live: live::LiveCache::default(),
knockout: false,
shape_of: 0,
fx_arts: FrameCache::new(lru::FX_ART_BUDGET, 256),
nested: 0,
backdrop: None,
blends: PtrMap::default(),
clip_paths: vec![],
clip_paints: FrameCache::new(lru::CLIP_BUDGET, 256),
stroke_slices: PtrMap::default(),
adjusts: HashMap::new(),
dim_images: None,
inline_depth: 0,
raster: vello_cpu::RasterizerSettings::default(),
isolation: None,
isolation_settled: false,
}
}
/// Render `doc` into a `width`×`height` image using `view` (document → pixel transform).
pub fn render(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions) -> Rendered {
self.render_as(doc, width, height, view, opts, false)
}
/// [`Self::render`], or with `inks` the ink amounts in the working CMYK space instead of
/// screen colours (4 bytes a pixel, see [`Self::render_region_inks`]).
fn render_as(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions, inks: bool) -> Rendered {
let start = now();
if opts.supersample && opts.anti_alias == AntiAlias::Art && opts.mask_view.is_none() {
let (w, h) = (width.clamp(1, u16::MAX as u32), height.clamp(1, u16::MAX as u32));
if let Some(k) = supersample_factor(w, h) {
let hard = RenderOptions { anti_alias: AntiAlias::None, supersample: false, ..opts.clone() };
let big = self.render_as(doc, w * k, h * k, Affine::scale(f64::from(k)) * view, &hard, inks);
let pixels = downsample(&big.pixels, big.width as usize, big.height as usize, k as usize);
self.stats.micros = now().saturating_sub(start);
return Rendered { width: w, height: h, pixels };
}
}
let prepared = proof::prepare(doc, opts);
let doc: &Document = &prepared;
let w = width.clamp(1, u16::MAX as u32) as u16;
let h = height.clamp(1, u16::MAX as u32) as u16;
// Raster filters (drop shadow, glows, blur) are rendered offscreen on this thread when the
// main context is multithreaded (see `fx::with_filters`).
let threads = self.threads;
self.stats = FrameStats::default();
let inv = view.inverse();
let visible = inv.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64));
let px = 1.0 / view.determinant().abs().sqrt().max(1e-12);
let frame = Frame { mt: threads > 0, doc, view, visible, px, opts, ink: Ink::Display };
self.begin_frame();
// View Opacity Mask: the mask's coverage in greyscale instead of the artwork.
if let Some(m) = opts.mask_view.and_then(|id| doc.node(id)).and_then(|n| n.mask.as_deref()) {
let pixels = self.mask_values(&frame, m, w, h).into_iter().flat_map(|v| [v, v, v, 255]).collect();
self.end_frame();
self.stats.micros = now().saturating_sub(start);
return Rendered { width: w as u32, height: h as u32, pixels };
}
let mut pixels = if inks || ink::blends_in_cmyk(doc, opts) {
// Blending in CMYK: one frame per ink plane, then their inks shown on screen.
let cmy = self.draw_frame(&Frame { ink: Ink::Cmy, ..frame }, w, h);
let stats = self.stats;
let k = self.draw_frame(&Frame { ink: Ink::K, ..frame }, w, h);
self.stats = stats;
if inks { ink::amounts(&cmy, &k) } else { ink::compose(&cmy, &k, opts.background) }
} else {
self.draw_frame(&frame, w, h)
};
self.end_frame();
if !inks {
proof::post(&mut pixels, opts);
}
self.stats.micros = now().saturating_sub(start);
Rendered { width: w as u32, height: h as u32, pixels }
}
/// Start a frame: cache entries used from now on are marked with its stamp.
fn begin_frame(&mut self) {
self.stamp += 1;
}
/// End a frame: drop cache entries not used for a few frames (every render, thumbnails too,
/// so the nodes they hold go with them).
fn end_frame(&mut self) {
let g = self.stamp;
if self.geom.len() > 1024 {
self.geom.retain(|_, e| g - e.stamp <= 3);
}
if self.strokes.len() > 1024 {
self.strokes.retain(|_, e| g - e.stamp <= 3);
}
if self.stroke_slices.len() > 256 {
self.stroke_slices.retain(|_, e| g - e.stamp <= 3);
}
if self.shadows.len() > 256 {
self.shadows.retain(|_, e| g - e.stamp <= 3);
}
if self.fx_geom.len() > 256 {
self.fx_geom.retain(|_, e| g - e.stamp <= 3);
}
// Filtered rasters are large: only those of the last frame stay.
self.pixel_fx.retain(|_, e| e.stamp == g);
self.texts.sweep(g);
self.clips.sweep(g);
self.clip_paints.sweep(g);
self.fx_arts.sweep(g);
self.brushes.entries.sweep(g);
self.live.sweep(g);
}
/// Draw frame `f` (`w`×`h` pixels): the background, the artboards and the page's art.
fn draw_frame(&mut self, f: &Frame, w: u16, h: u16) -> Vec<u8> {
let (threads, opts) = (self.threads, f.opts);
let slot = if threads == 0 { self.ctx_st.take() } else { self.ctx.take() };
let mut ctx = match slot {
Some(mut c) if c.width() == w && c.height() == h => {
c.reset();
c
}
_ => RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: threads, ..Default::default() }),
};
// `reset` keeps the threshold of the previous render: set it every time.
ctx.set_aliasing_threshold(opts.anti_alias.threshold());
let render_mode = if opts.precise { vello_cpu::RenderMode::OptimizeQuality } else { vello_cpu::RenderMode::OptimizeSpeed };
self.raster = vello_cpu::RasterizerSettings { render_mode, ..Default::default() };
if let Some(bg) = opts.background {
ctx.set_transform(Affine::IDENTITY);
ctx.set_paint(f.ink.fixed(bg));
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, h as f64));
}
if opts.artboards && !opts.outline {
// Paper: white on screen, no ink on the ink planes.
ctx.set_transform(f.view);
ctx.set_paint(peniko::Color::WHITE);
for ab in &f.doc.artboards {
ctx.fill_rect(&ab.rect);
}
}
(self.nested, self.backdrop) = (0, None);
self.clip_paths.clear();
if opts.trim && !f.doc.artboards.is_empty() {
let mut clip = BezPath::new();
for ab in &f.doc.artboards {
clip.extend(ab.rect.path_elements(0.1));
}
let blends = self.children_blend(&f.doc.layers);
let trim = Composite { clip: Some((&clip, FillRule::NonZero)), blends, ..Default::default() };
self.group(&mut ctx, f, trim, &mut |r, c, fr| r.draw_page(c, fr));
} else {
self.draw_page(&mut ctx, f);
}
ctx.flush();
let mut pm = Pixmap::new(w, h);
ctx.render_with(&mut pm, &mut self.resources, self.raster);
if threads == 0 {
self.ctx_st = Some(ctx);
} else {
self.ctx = Some(ctx);
}
pm.data_as_u8_slice().to_vec()
}
/// The page's art: the layers (or the pattern being edited), inside the page group when the
/// page has one.
fn draw_page(&mut self, ctx: &mut RenderContext, f: &Frame) {
if self.draw_pattern_edit(ctx, f) {
return;
}
let doc = f.doc;
// While editing an opacity mask its art is seen only through the mask.
let mask_layer = doc.mask_edit.map(|m| m.layer);
let layers: Vec<Arc<Node>> = doc.layers.iter().filter(|l| Some(l.id) != mask_layer).cloned().collect();
// Page Isolated Blending / Page Knockout Group: the page is a group of its own.
let page_group = !f.opts.outline && (doc.page_isolate || doc.page_knockout);
let knockout = page_group && doc.page_knockout;
let mut draw = |r: &mut Self, c: &mut RenderContext, fr: &Frame| {
r.knockout = knockout;
r.draw_children(c, fr, &layers, knockout);
r.knockout = false;
};
if page_group {
let blends = self.children_blend(&layers);
self.group(ctx, f, Composite { isolated: doc.page_isolate, blends, ..Default::default() }, &mut draw);
} else {
draw(self, ctx, f);
}
}
/// Render one artboard (or any document rect) at `scale` pixels per point, transparent or on white,
/// as exported: template layers and guides are left out.
pub fn render_region(&mut self, doc: &Document, region: Rect, scale: f64, white: bool) -> Rendered {
let opts = RenderOptions { background: white.then_some([255, 255, 255, 255]), skip_templates: true, ..Default::default() };
self.render_region_with(doc, region, scale, &opts)
}
/// Render a document rect at `scale` pixels per point with `opts`.
pub fn render_region_with(&mut self, doc: &Document, region: Rect, scale: f64, opts: &RenderOptions) -> Rendered {
let (w, h) = region_pixels(region, scale);
let view = Affine::scale(scale) * Affine::translate((-region.x0, -region.y0));
self.render(doc, w, h, view, opts)
}
/// `region` of `doc` as ink amounts in the working CMYK space, 4 bytes a pixel (0 = no ink):
/// drawn as the two ink planes of [`ink`], so CMYK colours keep their own inks (black type
/// stays black ink alone) and RGB ones are separated with the colour settings. Where nothing
/// is drawn (no background) there is no ink. CMYK exports draw this.
pub fn render_region_inks(&mut self, doc: &Document, region: Rect, scale: f64, opts: &RenderOptions) -> Vec<u8> {
let (w, h) = region_pixels(region, scale);
let view = Affine::scale(scale) * Affine::translate((-region.x0, -region.y0));
self.render_as(doc, w, h, view, opts, true).pixels
}
/// Render a single node (thumbnails, previews) fitted into `size`×`size` pixels.
pub fn render_thumbnail(&mut self, doc: &Document, id: NodeId, size: u32) -> Option<Rendered> {
self.render_node_thumbnail(doc, doc.node(id)?, size, None)
}
/// Render node `n` of `doc` (its resources) into `w`×`h` transparent pixels through `view`.
pub fn render_node(&mut self, doc: &Document, n: &Arc<Node>, w: u16, h: u16, view: Affine) -> Option<Rendered> {
let inv = view.inverse();
let visible = inv.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64));
let px = 1.0 / view.determinant().abs().sqrt().max(1e-12);
let mut ctx = single_threaded_context(w, h);
let opts = RenderOptions::default();
let frame = Frame { mt: false, doc, view, visible, px, opts: &opts, ink: Ink::Display };
self.begin_frame();
(self.knockout, self.nested, self.backdrop) = (false, 0, None);
self.clip_paths.clear();
self.draw_arc(&mut ctx, &frame, n);
ctx.flush();
let mut pm = Pixmap::new(w, h);
ctx.render(&mut pm, &mut self.resources);
self.end_frame();
Some(Rendered { width: w as u32, height: h as u32, pixels: pm.data_as_u8_slice().to_vec() })
}
/// Render any node (also one outside the tree, e.g. opacity-mask art) fitted into
/// `size`×`size` pixels, optionally over a solid premultiplied background.
pub fn render_node_thumbnail(&mut self, doc: &Document, n: &Node, size: u32, background: Option<[u8; 4]>) -> Option<Rendered> {
self.render_node_thumbnail_with(doc, n, size, background, &RenderOptions::default())
}
/// [`Self::render_thumbnail`] with `opts` (e.g. [`RenderOptions::image_mips`]).
pub fn render_thumbnail_with(&mut self, doc: &Document, id: NodeId, size: u32, opts: &RenderOptions) -> Option<Rendered> {
self.render_node_thumbnail_with(doc, doc.node(id)?, size, None, opts)
}
/// [`Self::render_node_thumbnail`] with `opts` (e.g. [`RenderOptions::image_mips`]).
pub fn render_node_thumbnail_with(
&mut self,
doc: &Document,
n: &Node,
size: u32,
background: Option<[u8; 4]>,
opts: &RenderOptions,
) -> Option<Rendered> {
let b = brush_fx::cull_bounds(n)?;
let s = (size as f64 - 2.0) / b.width().max(b.height()).max(1e-6);
let view = Affine::translate((size as f64 / 2.0, size as f64 / 2.0)) * Affine::scale(s) * Affine::translate(-b.center().to_vec2());
let w = size.clamp(1, u16::MAX as u32) as u16;
let mut ctx = single_threaded_context(w, w);
if let Some(bg) = background {
ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3]));
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, w as f64));
}
let frame = Frame { mt: false, doc, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts, ink: Ink::Display };
self.begin_frame();
(self.knockout, self.nested, self.backdrop) = (false, 0, None);
self.clip_paths.clear();
self.draw_node(&mut ctx, &frame, n, true);
ctx.flush();
let mut pm = Pixmap::new(w, w);
ctx.render(&mut pm, &mut self.resources);
self.end_frame();
Some(Rendered { width: size, height: size, pixels: pm.data_as_u8_slice().to_vec() })
}
/// Cached cull bounds of a node (containers union their cached children).
fn bounds_of(&mut self, a: &Arc<Node>) -> Option<Rect> {
let key = Arc::as_ptr(a) as usize;
if let Some(e) = self.geom.get_mut(&key)
&& Arc::ptr_eq(&e.node, a)
{
e.stamp = self.stamp;
return e.bounds;
}
let b = match &a.kind {
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !fx::has_object_fx(a) => {
let mut acc: Option<Rect> = None;
// An Image Trace object's hidden source image shows in some of its views.
let source = a.trace.is_some().then(|| children.first()).flatten();
for c in children {
if c.visible || source.is_some_and(|s| Arc::ptr_eq(s, c)) {
acc = vectorcraft_geom::union_opt(acc, self.bounds_of(c));
}
}
acc
}
_ => brush_fx::cull_bounds(a),
};
self.geom.insert(key, GeomEntry { node: a.clone(), bounds: b, path: None, stamp: self.stamp });
b
}
/// Cached region a clip group's clipping path `a` clips to ([`effects::clip_outline`]):
/// outlining text and uniting shapes is too slow to repeat every frame.
fn clip_of(&mut self, a: &Arc<Node>) -> Option<Arc<(BezPath, FillRule)>> {
let key = Arc::as_ptr(a) as usize;
if let Some((node, region)) = self.clips.get(&key, self.stamp)
&& Arc::ptr_eq(node, a)
{
return region.clone();
}
let region = effects::clip_outline(a).map(Arc::new);
let bytes = size_of::<ClipEntry>() + region.as_ref().map_or(0, |r| lru::path_bytes(&r.0));
self.clips.insert(key, (a.clone(), region.clone()), bytes, self.stamp);
region
}
/// Cached fills and strokes of clipping path `a` ([`Node::clip_paint`]): the parts keep their
/// allocations between frames, so their own geometry stays cached.
fn clip_paint_of(&mut self, a: &Arc<Node>) -> (Option<Arc<Node>>, Option<Arc<Node>>) {
let key = Arc::as_ptr(a) as usize;
if let Some((node, fill, stroke)) = self.clip_paints.get(&key, self.stamp)
&& Arc::ptr_eq(node, a)
{
return (fill.clone(), stroke.clone());
}
let paint = a.clip_paint();
let (fill, stroke) = (paint.fill.map(Arc::new), paint.stroke.map(Arc::new));
let bytes = size_of::<ClipPaintEntry>() + [&fill, &stroke].iter().filter_map(|p| p.as_deref()).map(lru::node_bytes).sum::<usize>();
self.clip_paints.insert(key, (a.clone(), fill.clone(), stroke.clone()), bytes, self.stamp);
(fill, stroke)
}
/// Cached BezPath of a path node.
fn path_of(&mut self, a: &Arc<Node>) -> Option<Arc<BezPath>> {
let key = Arc::as_ptr(a) as usize;
if let Some(e) = self.geom.get(&key)
&& Arc::ptr_eq(&e.node, a)
&& let Some(p) = &e.path
{
return Some(p.clone());
}
let p = Arc::new(a.path_data()?.to_bezpath());
let bounds = self.bounds_of(a);
self.geom.insert(key, GeomEntry { node: a.clone(), bounds, path: Some(p.clone()), stamp: self.stamp });
Some(p)
}
/// Whether `a` is left out of this frame: hidden, a skipped template or guide, or culled.
fn skipped(&mut self, f: &Frame, a: &Arc<Node>) -> bool {
let skipped_template = f.opts.skip_templates && matches!(a.kind, NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. });
if !a.visible || skipped_template || f.opts.hidden.contains(&a.id) {
return true;
}
match self.bounds_of(a) {
Some(b) => {
let pad = f.px * 2.0;
// Level of detail: leaves smaller than a quarter pixel are invisible.
let culled =
!rects_overlap(b.inflate(pad, pad), f.visible) || (!a.is_container() && b.width() < f.px * 0.25 && b.height() < f.px * 0.25);
self.stats.culled += culled as usize;
culled
}
None => !a.is_container(),
}
}
fn draw_arc(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>) {
if self.skipped(f, a) {
return;
}
if let Some(m) = a.mask.as_deref()
&& !m.disabled
&& !f.opts.outline
&& !self.is_shape(a)
{
// A masked object is a non-isolated group of its own.
let mask = Some(self.opacity_mask(f, m, ctx.width(), ctx.height()));
let blends = a.blend != vectorcraft_color::BlendMode::Normal || (!a.isolate && self.blends_through(a));
let bounds = if blends { self.bounds_of(a) } else { None };
self.group(ctx, f, Composite { mask, blends, bounds, ..Default::default() }, &mut |r, c, fr| r.draw_arc_body(c, fr, a));
return;
}
self.draw_arc_body(ctx, f, a);
}
/// Render an opacity mask's art offscreen and turn its luminance into a coverage mask.
fn opacity_mask(&mut self, f: &Frame, m: &vectorcraft_doc::OpacityMask, w: u16, h: u16) -> vello_cpu::Mask {
vello_cpu::Mask::from_parts(self.mask_values(f, m, w, h), w, h)
}
/// The coverage of opacity mask `m` per output pixel (row-major): its art rendered offscreen,
/// as luminance ([`mask_value`]).
fn mask_values(&mut self, f: &Frame, m: &vectorcraft_doc::OpacityMask, w: u16, h: u16) -> Vec<u8> {
let mut mctx = f.offscreen_context(w, h);
// Mask art is a picture of its own: it takes no part in a knockout group around the object,
// and its luminance is that of its screen colours.
let outer =
(std::mem::take(&mut self.knockout), std::mem::take(&mut self.nested), self.backdrop.take(), std::mem::take(&mut self.clip_paths));
self.draw_node(&mut mctx, &Frame { mt: false, ink: Ink::Display, ..*f }, &m.art, true);
(self.knockout, self.nested, self.backdrop, self.clip_paths) = outer;
mctx.flush();
let mut pm = Pixmap::new(w, h);
mctx.render_with(&mut pm, &mut self.resources, self.raster);
pm.data().iter().map(|p| mask_value(p.r, p.g, p.b, p.a, m.clip, m.invert)).collect()
}
/// Everything [`Self::draw_arc`] does after culling.
fn draw_arc_body(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>) {
// Fast path for plain paths: cached geometry, opacity folded into the paint.
// A blend mode on a single plain fill applies per draw (exact for one paint operation);
// a blend layer would composite the whole viewport.
let blended = a.blend != vectorcraft_color::BlendMode::Normal;
if let NodeKind::Path { rule, guide: false, .. } = &a.kind
&& !f.opts.outline
&& (!blended || single_plain_fill(a))
&& !a.isolate
&& !fx::has_fx(a)
&& (self.opacity_of(a) >= 1.0 || painted_items(a) == 1)
&& let Some(bp) = self.path_of(a)
{
self.alpha = self.opacity_of(a).clamp(0.0, 1.0);
self.cur = Some(a.clone());
if blended {
ctx.set_blend_mode(blend_mode(a.blend));
}
self.draw_shape(ctx, f, a, &bp, *rule);
if blended {
ctx.set_blend_mode(blend_mode(vectorcraft_color::BlendMode::Normal));
}
self.cur = None;
self.alpha = 1.0;
self.stats.drawn += 1;
return;
}
if fx::has_object_fx(a) {
return self.draw_object_fx(ctx, f, a, true);
}
if let NodeKind::Text(t) = &a.kind
&& self.opacity_of(a) >= 1.0
&& a.blend == vectorcraft_color::BlendMode::Normal
&& !fx::has_fx(a)
{
let t = &*f.doc.inline_resolved(t);
let g = match f.text_snap(t) {
Some(xf) => Arc::new(text_geom_snapped(t, Some(xf))),
None => self.text_geom_of(a, t),
};
self.draw_text_geom(ctx, f, a, t, &g);
self.stats.drawn += 1;
return;
}
if vectorcraft_doc::live::is_live(a) || effects::has_pathfinder(a) {
return self.draw_live(ctx, f, a);
}
// Its live-effect geometry depends on the node alone, so it can be kept across frames.
let outer = self.fx_owner.replace(a.clone());
self.draw_node(ctx, f, a, true);
self.fx_owner = outer;
}
fn draw_node(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, force: bool) {
if !force && (!n.visible || f.opts.hidden.contains(&n.id)) {
return;
}
if force {
// Bounds were already checked by draw_arc (or the caller wants it drawn regardless).
} else if let Some(b) = fx::cull_bounds(n) {
let pad = f.px * 2.0;
if !rects_overlap(b.inflate(pad, pad), f.visible) {
self.stats.culled += n.count();
return;
}
} else if !n.is_container() {
return;
}
// Layer Options on screen: a layer whose Preview is off draws in outline, and Dim Images
// fades its images (exports and thumbnails, which leave templates out, ignore both).
// (A template is dimmed as a whole already.)
if let NodeKind::Layer { preview, dim_images, template, .. } = &n.kind
&& f.opts.dim_templates
&& !f.opts.skip_templates
{
if !preview && !f.opts.outline {
let opts = RenderOptions { outline: true, ..f.opts.clone() };
return self.draw_node(ctx, &Frame { opts: &opts, ..*f }, n, force);
}
if let Some(p) = dim_images.filter(|_| !template) {
let outer = self.dim_images;
let dim = f32::from(p).clamp(0.0, 100.0) / 100.0;
self.dim_images = Some(outer.map_or(dim, |o| o.min(dim)));
self.draw_layer_node(ctx, f, n);
self.dim_images = outer;
return;
}
}
self.draw_layer_node(ctx, f, n);
}
/// [`Self::draw_node`] after culling and Layer Options.
fn draw_layer_node(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node) {
// Isolation mode: the art around the isolated container draws dimmed (#833).
if let Some((isolated, path)) = self.isolation_path(f) {
// A container on the way down to it draws as usual: its members decide.
if path.contains(&n.id) && n.id != isolated {
return self.draw_layer_node_now(ctx, f, n);
}
self.isolation_settled = true;
if n.id == isolated {
self.draw_layer_node_now(ctx, f, n);
} else {
self.dimmed(ctx, f, &mut |r, c, fr| r.draw_layer_node_now(c, fr, n));
}
self.isolation_settled = false;
return;
}
self.draw_layer_node_now(ctx, f, n);
}
/// In isolation mode, while drawing outside the isolated container and the art dimmed around
/// it: the isolated container and its layers and groups, from the top layer down to it.
fn isolation_path(&mut self, f: &Frame) -> Option<(NodeId, Vec<NodeId>)> {
let isolated = f.opts.isolated.filter(|_| !self.isolation_settled)?;
let doc = std::ptr::from_ref(f.doc) as usize;
if self.isolation.as_ref().is_none_or(|(stamp, d, id, _)| (*stamp, *d, *id) != (self.stamp, doc, isolated)) {
self.isolation = Some((self.stamp, doc, isolated, f.doc.ancestry(isolated).unwrap_or_default()));
}
let path = self.isolation.as_ref().map(|(.., path)| path.clone()).filter(|p| !p.is_empty())?;
Some((isolated, path))
}
/// `draw` at [`ISOLATION_DIM`], with nothing inside dimmed again.
fn dimmed(&mut self, ctx: &mut RenderContext, f: &Frame, draw: &mut group::Content) {
let settled = std::mem::replace(&mut self.isolation_settled, true);
let comp = Composite { opacity: ISOLATION_DIM, ..Default::default() };
self.group(ctx, f, comp, draw);
self.isolation_settled = settled;
}
/// [`Self::draw_layer_node`] past isolation mode's dimming.
fn draw_layer_node_now(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node) {
if fx::has_object_fx(n) {
return self.draw_object_fx(ctx, f, &Arc::new(n.clone()), false);
}
let outline = f.opts.outline;
let template_dim = matches!(n.kind, NodeKind::Layer { template: true, .. }) && f.opts.dim_templates;
let opacity = if template_dim { self.opacity_of(n) * 0.5 } else { self.opacity_of(n) };
// Whether this container's children knock each other out.
let knockout = !outline && n.knocks_out(self.knockout);
let enclosing = std::mem::replace(&mut self.knockout, knockout);
if !outline && (opacity < 1.0 || n.blend != vectorcraft_color::BlendMode::Normal || n.isolate || knockout) {
let blends = self.blends_through(n);
let bounds = if blends { self.node_bounds(n) } else { None };
let comp = Composite { blend: n.blend, opacity, isolated: n.isolate, blends, bounds, ..Default::default() };
self.group(ctx, f, comp, &mut |r, c, fr| r.draw_content(c, fr, n, knockout));
} else {
self.draw_content(ctx, f, n, knockout);
}
self.knockout = enclosing;
self.stats.drawn += 1;
}
/// What `n` draws inside its transparency group (`knockout`: its children knock each other out).
fn draw_content(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, knockout: bool) {
match &n.kind {
NodeKind::Group { children, clip: false } if f.opts.trace_views && !f.opts.outline && n.trace_view() != TraceView::Result => {
self.draw_trace_view(ctx, f, children, n.trace_view(), knockout)
}
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
self.draw_children(ctx, f, children, knockout)
}
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } if f.opts.outline => {
for c in children {
self.draw_node(ctx, f, c, false);
}
}
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
// Nothing to clip by hides the clipped art (as in the SVG and PDF output). A clip
// group doesn't isolate: blending inside it reaches the art below. The clipping
// path's fill paints behind the clipped art and its stroke over it, unclipped.
if let Some((clip, rest)) = children.split_first()
&& let Some(region) = self.clip_of(clip)
{
let (fill, stroke) = self.clip_paint_of(clip);
// An image inside its own frame painted straight into the clipping path, without
// a clip layer (envelopes cut distorted images into many such pieces).
if let ([image], None, None) = (rest, &fill, &stroke)
&& let NodeKind::Image(im) = &image.kind
&& plain_image(image, im, &region.0)
{
self.draw_image_in(ctx, f, im, Some(&region));
self.stats.drawn += 1;
return;
}
let blends = self.blends_through(n);
let bounds = if blends { Some(region.0.bounding_box()) } else { None };
let comp = Composite { clip: Some((&region.0, region.1)), blends, bounds, ..Default::default() };
self.group(ctx, f, comp, &mut |r, c, fr| {
if let Some(fill) = &fill {
r.draw_arc(c, fr, fill);
}
r.draw_children(c, fr, rest, knockout)
});
if let Some(stroke) = &stroke {
self.draw_arc(ctx, f, stroke);
}
}
}
NodeKind::Path { path, rule, guide, .. } => {
let bp = path.to_bezpath();
if *guide {
self.hairline(ctx, f, &bp, [0x4a, 0xd8, 0xff, 255]);
} else {
self.draw_shape(ctx, f, n, &bp, *rule);
}
}
NodeKind::Compound { children, rule } => {
let mut bp = BezPath::new();
for c in children {
if let Some(p) = c.path_data() {
bp.extend(p.to_bezpath());
}
}
self.draw_shape(ctx, f, n, &bp, *rule);
}
NodeKind::Text(t) => self.draw_text(ctx, f, n, t),
NodeKind::Image(im) => self.draw_image(ctx, f, im),
NodeKind::SymbolInstance { symbol, xf } => {
if let Some(sym) = f.doc.symbols.iter().find(|s| &s.name == symbol) {
let mut art = brush_fx::instance_art(&sym.art, n);
art.transform(*xf, false);
self.draw_node(ctx, f, &art, true);
}
}
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) => {
self.draw_live_node(ctx, f, n)
}
}
}
/// An Image Trace object's `children` (the hidden source image, then the traced shapes) as
/// `view` shows them on screen.
fn draw_trace_view(&mut self, ctx: &mut RenderContext, f: &Frame, children: &[Arc<Node>], view: TraceView, knockout: bool) {
let Some((source, shapes)) = children.split_first() else { return };
if view.shows_source() && matches!(source.kind, NodeKind::Image(_)) {
self.draw_node(ctx, f, source, true);
}
if view.shows_result() {
self.draw_children(ctx, f, shapes, knockout);
}
if view.shows_outlines() {
let opts = RenderOptions { outline: true, ..f.opts.clone() };
let frame = Frame { opts: &opts, ..*f };
for c in shapes {
self.draw_node(ctx, &frame, c, false);
}
}
}
/// The children of a group: as the elements of a knockout group when `knockout`.
fn draw_children(&mut self, ctx: &mut RenderContext, f: &Frame, children: &[Arc<Node>], knockout: bool) {
if knockout {
self.draw_knockout(ctx, f, children);
} else if let Some((_, path)) = self.isolation_path(f) {
// Isolation mode, in a container on the way down to the isolated one: each run of
// members around it draws dimmed as one (#833).
let mut rest = children;
while let Some(i) = rest.iter().position(|c| path.contains(&c.id)) {
let (around, from) = rest.split_at(i);
if !around.is_empty() {
self.dimmed(ctx, f, &mut |r, c, fr| around.iter().for_each(|n| r.draw_arc(c, fr, n)));
}
let Some((on_path, after)) = from.split_first() else { break };
self.draw_arc(ctx, f, on_path);
rest = after;
}
if !rest.is_empty() {
self.dimmed(ctx, f, &mut |r, c, fr| rest.iter().for_each(|n| r.draw_arc(c, fr, n)));
}
} else {
for c in children {
self.draw_arc(ctx, f, c);
}
}
}
/// Cull bounds of `n`: cached when it is a node of the tree being drawn.
fn node_bounds(&mut self, n: &Node) -> Option<Rect> {
match self.geom.get(&(n as *const Node as usize)) {
// The entry keeps its node alive, so the address is that node's.
Some(e) if std::ptr::eq(Arc::as_ptr(&e.node), n) => e.bounds,
_ => fx::cull_bounds(n),
}
}
/// The children of a knockout group ([`Node::knockout_elements`]). Each element first erases
/// what the elements below it drew wherever it paints (its knockout shape: its coverage at full
/// object opacity without its opacity mask, or as drawn when its opacity and mask define the
/// knockout shape), then adds itself: it composites against the group's backdrop instead of
/// over the elements below it (the art below the group when the group is non-isolated and
/// drawn over a copy of it, see [`Self::group`]).
fn draw_knockout(&mut self, ctx: &mut RenderContext, f: &Frame, children: &[Arc<Node>]) {
let backdrop = self.backdrop.take();
for c in Node::knockout_elements(children) {
if self.skipped(f, c) {
continue;
}
self.knockout_pass(ctx, f, c, Compose::DestOut, None);
self.knockout_pass(ctx, f, c, Compose::Plus, backdrop.as_ref());
}
self.backdrop = backdrop;
}
/// Whether `n` is the knockout element being drawn as its shape (see [`Self::draw_knockout`]).
fn is_shape(&self, n: &Node) -> bool {
self.shape_of == n as *const Node as usize
}
/// The object opacity `n` is drawn with (full while it is drawn as its knockout shape).
pub(crate) fn opacity_of(&self, n: &Node) -> f32 {
if self.is_shape(n) { 1.0 } else { n.opacity }
}
fn hairline(&mut self, ctx: &mut RenderContext, f: &Frame, bp: &BezPath, rgba: [u8; 4]) {
ctx.set_transform(Affine::IDENTITY);
let mut screen = bp.clone();
screen.apply_affine(f.view);
ctx.set_stroke(kurbo::Stroke::new(1.0));
ctx.set_paint(f.ink.fixed(rgba));
ctx.stroke_path(&screen);
}
fn draw_shape(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, bp: &BezPath, rule: FillRule) {
if !drawable(bp, f.view, n) {
return;
}
if fx::has_fx(n) {
return self.draw_shape_fx(ctx, f, n, bp, rule);
}
if f.opts.outline {
self.hairline(ctx, f, bp, [0, 0, 0, 255]);
return;
}
// Bounds only matter to gradients and patterns (their geometry is relative to the object).
let solid = |p: &vectorcraft_color::Paint| matches!(p, vectorcraft_color::Paint::Solid { .. } | vectorcraft_color::Paint::None);
let needs_bounds = n.appearance.items.iter().any(|i| match i {
AppearanceItem::Fill(fl) => !solid(&fl.paint),
AppearanceItem::Stroke(st) => !solid(&st.paint),
});
let bounds = if needs_bounds { bp.bounding_box() } else { Rect::ZERO };
for item in &n.appearance.items {
match item {
AppearanceItem::Fill(fl) => {
if !fl.visible || fl.paint.is_none() {
continue;
}
let layered = fl.opacity < 1.0 || fl.blend != vectorcraft_color::BlendMode::Normal;
if layered {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None);
}
ctx.set_transform(f.view);
if paint::set_paint(ctx, &fl.paint, bounds, f) {
self.fold_alpha(ctx, f.ink, &fl.paint);
ctx.set_fill_rule(fill_rule(rule));
ctx.fill_path(bp);
}
if layered {
ctx.pop_layer();
}
}
AppearanceItem::Stroke(st) => {
if !st.visible || st.paint.is_none() || st.width <= 0.0 {
continue;
}
if st.brush.is_some() && self.draw_brush(ctx, f, n, bp, st) {
continue;
}
self.draw_stroke(ctx, f, Affine::IDENTITY, bp, rule, st, bounds);
}
}
}
}
/// Multiply the folded object opacity into a solid paint.
fn fold_alpha(&self, ctx: &mut RenderContext, ink: Ink, p: &vectorcraft_color::Paint) {
if self.alpha < 1.0
&& let vectorcraft_color::Paint::Solid { color, .. } = p
{
ctx.set_paint(ink.color(color, self.alpha));
}
}
/// Paint stroke `st` of the shape `bp` (in the space `xf` maps to the document; `bounds`: its
/// geometric bounds there) with its alignment, dashes, profile, arrowheads, opacity and blend.
#[allow(clippy::too_many_arguments)]
fn draw_stroke(&mut self, ctx: &mut RenderContext, f: &Frame, xf: Affine, bp: &BezPath, rule: FillRule, st: &StrokeLayer, bounds: Rect) {
let pieces = effects::stroke::stroke_pieces(bp, st);
// The line and its heads overlap: they take the opacity once, in one layer, instead of
// each folding it into its paint.
let folded = if pieces.heads.is_empty() { 1.0 } else { std::mem::replace(&mut self.alpha, 1.0) };
let opacity = st.opacity * folded;
let layered = opacity < 1.0 || st.blend != vectorcraft_color::BlendMode::Normal || st.align == StrokeAlign::Outside;
if layered {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(st.blend)), Some(opacity), None, None);
}
ctx.set_transform(f.view * xf);
let closed = effects::stroke::is_closed(bp);
// Keep hairlines visible when zoomed far out (at least ~1 device pixel).
let width = effects::stroke::aligned_width(st, closed).max(f.px * 0.5 / xf.determinant().abs().sqrt().max(1e-9));
let inside = st.align == StrokeAlign::Inside && closed;
if inside {
ctx.set_fill_rule(fill_rule(rule));
ctx.push_clip_layer(bp);
}
// One paint box for the line and the heads, so a gradient runs on into the heads.
let paint_bounds = bounds.inflate(st.width / 2.0, st.width / 2.0);
if let Some(g) = st.path_gradient() {
self.fill_path_gradient(ctx, f, bp, rule, st, &pieces, width, &g.gradient);
} else if paint::set_paint(ctx, &st.paint, paint_bounds, f) {
self.fold_alpha(ctx, f.ink, &st.paint);
ctx.set_fill_rule(peniko::Fill::NonZero);
match self.cached_stroke(f, &pieces.line, st, width) {
Some(o) => ctx.fill_path(&o),
None => ctx.fill_path(&effects::stroke::line_outline(&pieces.line, st, width, f.px * 0.25)),
}
for head in &pieces.heads {
ctx.fill_path(&head.outline);
}
}
if inside {
ctx.pop_layer();
}
if st.align == StrokeAlign::Outside && closed {
// Punch out the interior.
ctx.set_blend_mode(BlendMode::new(Mix::Normal, Compose::DestOut));
ctx.set_paint(peniko::Color::BLACK);
ctx.set_fill_rule(fill_rule(rule));
ctx.fill_path(bp);
ctx.set_blend_mode(BlendMode::default());
}
if layered {
ctx.pop_layer();
}
if !pieces.heads.is_empty() {
self.alpha = folded;
}
}
/// Paint the line and arrowheads (`pieces`) of stroke `st` of `bp` (filled with `rule`) with
/// its gradient `g` laid along or across the path ([`effects::stroke::gradient_slices`]). Each
/// piece clips the slices, grown a pixel into each other and each replacing what it covers, so
/// neighbours meet without seams and translucent colours paint once.
#[allow(clippy::too_many_arguments)]
fn fill_path_gradient(
&mut self,
ctx: &mut RenderContext,
f: &Frame,
bp: &BezPath,
rule: FillRule,
st: &StrokeLayer,
pieces: &effects::stroke::StrokePieces,
width: f64,
g: &vectorcraft_color::Gradient,
) {
let level = (f.px * 0.25).log2().floor() as i32;
let make = || -> Slices {
let tol = 2f64.powi(level);
// Reach a pixel past the stroke, so its antialiased edge has colour under it.
let slices = effects::stroke::gradient_slices(bp, rule, st, tol, f.px + tol, f.px);
Arc::new(
slices
.into_iter()
.map(|s| {
let (b, paint) = (s.shape.bounding_box(), s.paint(g));
(s.shape, paint, b)
})
.collect(),
)
};
let slices = match self.cur.clone() {
Some(node) => {
let key = (st as *const StrokeLayer as usize, level);
match self.stroke_slices.get_mut(&key) {
Some(e) if Arc::ptr_eq(&e.node, &node) => {
e.stamp = self.stamp;
e.slices.clone()
}
_ => {
let slices = make();
self.stroke_slices.insert(key, SliceEntry { node, slices: slices.clone(), stamp: self.stamp });
slices
}
}
}
None => make(),
};
let line = match self.cached_stroke(f, &pieces.line, st, width) {
Some(o) => o,
None => Arc::new(effects::stroke::line_outline(&pieces.line, st, width, f.px * 0.25)),
};
ctx.set_fill_rule(peniko::Fill::NonZero);
for clip in std::iter::once(&*line).chain(pieces.heads.iter().map(|h| &h.outline)) {
let cb = clip.bounding_box();
if clip.elements().is_empty() || !cb.is_finite() {
continue;
}
ctx.push_clip_layer(clip);
ctx.set_blend_mode(BlendMode::new(Mix::Normal, Compose::Copy));
for (shape, paint, b) in slices.iter() {
if rects_overlap(*b, cb) && paint::set_paint(ctx, paint, *b, f) {
ctx.fill_path(shape);
}
}
ctx.set_blend_mode(BlendMode::default());
ctx.pop_layer();
}
}
/// The line part of a stroke of the fast-path node `self.cur` (see
/// [`effects::stroke::line_outline`]), cached across frames. The tolerance (vello's 0.25
/// device px) is bucketed to powers of two, never coarser than needed, so zooming reuses
/// outlines until the level changes.
fn cached_stroke(&mut self, f: &Frame, line: &BezPath, st: &StrokeLayer, width: f64) -> Option<Arc<BezPath>> {
let node = self.cur.clone()?;
let level = (f.px * 0.25).log2().floor() as i32;
let key = (st as *const StrokeLayer as usize, width.to_bits(), level);
if let Some(e) = self.strokes.get_mut(&key)
&& Arc::ptr_eq(&e.node, &node)
{
e.stamp = self.stamp;
return Some(e.outline.clone());
}
let outline = Arc::new(effects::stroke::line_outline(line, st, width, 2f64.powi(level)));
self.strokes.insert(key, StrokeEntry { node, outline: outline.clone(), stamp: self.stamp });
Some(outline)
}
fn draw_text(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject) {
let t = &*f.doc.inline_resolved(t);
let g = text_geom_snapped(t, f.text_snap(t));
self.draw_text_geom(ctx, f, n, t, &g);
}
/// Draw the inline graphics of type `t` (laid out in `g`): each symbol's art through the
/// normal node path, placed by the layout (inside the text's own transparency group, so its
/// opacity and blend mode apply). A missing symbol draws nothing.
fn draw_inlines(&mut self, ctx: &mut RenderContext, f: &Frame, t: &TextObject, g: &TextGeom) {
if g.inlines.is_empty() && !t.runs.iter().any(|r| r.inline.is_some()) {
return;
}
if self.inline_depth >= MAX_INLINE_DEPTH {
return;
}
for r in t.runs.iter().filter_map(|r| r.inline.as_ref()) {
if !f.doc.symbols.iter().any(|s| s.name == r.symbol) && !MISSING_INLINE_LOGGED.swap(true, std::sync::atomic::Ordering::Relaxed) {
log::warn!("inline graphic shows symbol {:?}, which the document doesn't have: it draws nothing", r.symbol);
}
}
self.inline_depth += 1;
for ig in &g.inlines {
let Some(art) = t.runs.get(ig.run).and_then(|r| r.inline.as_ref()) else { continue };
let Some(sym) = f.doc.symbols.iter().find(|s| s.name == art.symbol) else { continue };
let mut node = (*sym.art).clone();
// Strokes scale with the art (it is sized to the type, as in the SVG `<use>`).
node.transform(t.xf * ig.xf * f.doc.symbol_natural_xf(&art.symbol), true);
self.draw_node(ctx, f, &node, true);
}
self.inline_depth -= 1;
}
/// Cached glyph geometry for a text node (keyed by Arc identity like paths).
fn text_geom_of(&mut self, a: &Arc<Node>, t: &TextObject) -> Arc<TextGeom> {
let fonts = vectorcraft_text::FontDb::global().generation();
if fonts != self.text_fonts {
self.texts.clear();
self.text_fonts = fonts;
}
let key = Arc::as_ptr(a) as usize;
if let Some((node, g)) = self.texts.get(&key, self.stamp)
&& Arc::ptr_eq(node, a)
{
return g.clone();
}
let g = Arc::new(text_geom(t));
self.texts.insert(key, (a.clone(), g.clone()), g.bytes(), self.stamp);
g
}
fn draw_text_geom(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject, g: &TextGeom) {
let xf = f.view * t.xf;
if f.opts.outline {
let mut p = g.all.clone();
p.apply_affine(xf);
ctx.set_transform(Affine::IDENTITY);
ctx.set_stroke(kurbo::Stroke::new(1.0));
ctx.set_paint(peniko::Color::BLACK);
ctx.stroke_path(&p);
self.draw_inlines(ctx, f, t, g);
return;
}
let tb = t.xf.transform_rect_bbox(g.bounds);
// Overprinting characters multiply per draw, like overprinting fills and strokes
// ([`proof`]); the object blend mode is Normal here or applied by an enclosing layer.
let overprints = proof::overprints(f.opts);
let overprint = |ctx: &mut RenderContext, on: bool| {
if overprints {
ctx.set_blend_mode(if on { blend_mode(vectorcraft_color::BlendMode::Multiply) } else { BlendMode::default() });
}
};
// The object's own fills and strokes paint the whole outline: those below the Characters
// row under the characters' own fill and stroke, the others over them.
let (below, above) = n.appearance.split_contents();
let all = (!n.appearance.items.is_empty()).then(|| {
let mut all = g.all.clone();
all.apply_affine(t.xf);
all
});
if let Some(all) = &all {
self.draw_text_items(ctx, f, n, below, all, tb);
}
if f.opts.highlight_substitutions {
let setup = &f.doc.setup;
for (on, path) in [(setup.highlight_substituted_fonts, &g.substituted_fonts), (setup.highlight_substituted_glyphs, &g.substituted_glyphs)]
{
if on && !path.elements().is_empty() {
ctx.set_transform(xf);
ctx.set_fill_rule(peniko::Fill::NonZero);
ctx.set_paint(SUBSTITUTED);
ctx.fill_path(path);
}
}
}
for (i, run) in t.runs.iter().enumerate() {
let path = g.run(i);
let path = path.as_ref();
if path.elements().is_empty() {
continue;
}
ctx.set_transform(xf);
ctx.set_fill_rule(peniko::Fill::NonZero);
if paint::set_paint(ctx, &run.style.fill, g.bounds, f) {
self.fold_alpha(ctx, f.ink, &run.style.fill);
overprint(ctx, run.style.overprint_fill);
ctx.fill_path(path);
}
if run.style.has_stroke() {
// Character strokes are drawn in text space, with their cap, join and dashes.
overprint(ctx, run.style.overprint_stroke);
self.draw_stroke(ctx, f, t.xf, path, FillRule::NonZero, &run.style.stroke_layer(), g.bounds);
}
}
overprint(ctx, false);
self.draw_inlines(ctx, f, t, g);
if let Some(all) = &all {
self.draw_text_items(ctx, f, n, above, all, tb);
}
}
/// Paint some of type `n`'s own fills and strokes (`items`) on its glyph outlines `all`
/// (document space; `tb` their bounds). Fills composite with their own opacity and blend mode;
/// strokes take every stroke option, as on paths.
fn draw_text_items(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, items: &[AppearanceItem], all: &BezPath, tb: Rect) {
for item in items.iter().filter(|i| i.visible() && !i.paint().is_none()) {
match item {
AppearanceItem::Fill(fl) => {
let layered = fl.opacity < 1.0 || fl.blend != vectorcraft_color::BlendMode::Normal;
if layered {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None);
}
ctx.set_transform(f.view);
if paint::set_paint(ctx, &fl.paint, tb, f) {
ctx.set_fill_rule(peniko::Fill::NonZero);
ctx.fill_path(all);
}
if layered {
ctx.pop_layer();
}
}
AppearanceItem::Stroke(st) if st.width > 0.0 => {
if st.brush.is_none() || !self.draw_brush(ctx, f, n, all, st) {
self.draw_stroke(ctx, f, Affine::IDENTITY, all, FillRule::NonZero, st, tb);
}
}
AppearanceItem::Stroke(_) => {}
}
}
}
fn draw_image(&mut self, ctx: &mut RenderContext, f: &Frame, im: &vectorcraft_doc::ImageObject) {
self.draw_image_in(ctx, f, im, None);
}
/// Draw an image, or with `area` (a region in the document inside the image's frame, see
/// [`plain_image`]) the image painted into that region.
fn draw_image_in(&mut self, ctx: &mut RenderContext, f: &Frame, im: &vectorcraft_doc::ImageObject, area: Option<&(BezPath, FillRule)>) {
let rect = Rect::new(0.0, 0.0, im.width as f64, im.height as f64);
// Outline mode draws the image's frame, and with Document Setup → Show Images in Outline
// Mode its pixels in greyscale under the frame.
let outline = f.opts.outline;
// With mips the full pixels are only looked at (not marked used) until they're drawn.
let mips = f.opts.image_mips;
let pixels = if outline && !f.doc.setup.outline_images { None } else { self.image_pixmap(f.doc, &im.key, outline, !mips) };
if let Some((key, pm)) = pixels {
let (key, pm) = if outline { (key, pm) } else { ink::ink_image(&key, &pm, f.ink, f.doc.images.get(&im.key), !mips) };
let pm = if mips { image_mip(&key, pm, f.view * im.xf * Affine::scale_non_uniform(im.width as f64, im.height as f64)) } else { pm };
let sx = im.width as f64 / pm.width().max(1) as f64;
let sy = im.height as f64 / pm.height().max(1) as f64;
let quality = if f.opts.smooth_images { peniko::ImageQuality::Medium } else { peniko::ImageQuality::Low };
let sampler = peniko::ImageSampler { quality, ..Default::default() };
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler });
match area {
Some((bp, rule)) => {
ctx.set_transform(f.view);
ctx.set_paint_transform(im.xf * Affine::scale_non_uniform(sx, sy));
ctx.set_fill_rule(fill_rule(*rule));
ctx.fill_path(bp);
}
None => {
ctx.set_transform(f.view * im.xf);
ctx.set_paint_transform(Affine::scale_non_uniform(sx, sy));
ctx.fill_rect(&rect);
}
}
ctx.reset_paint_transform();
// A dimmed layer's images: white over them, so they show at that opacity on paper.
if let Some(dim) = self.dim_images.filter(|d| *d < 1.0 && !outline) {
ctx.set_paint(f.ink.fixed([255, 255, 255, ((1.0 - dim) * 255.0).round() as u8]));
match area {
Some((bp, rule)) => {
ctx.set_transform(f.view);
ctx.set_fill_rule(fill_rule(*rule));
ctx.fill_path(bp);
}
None => {
ctx.set_transform(f.view * im.xf);
ctx.fill_rect(&rect);
}
}
}
}
if outline {
let mut p = rect.to_path(0.1);
p.apply_affine(im.xf);
self.hairline(ctx, f, &p, [0, 0, 0, 255]);
}
}
/// Name image `key` recoloured by `map` (its hash `hash`) as `derived` (the key the adjusted
/// art's image takes): drawing `derived` draws the recoloured pixels, made when first drawn and
/// kept with the decoded images. Names past a limit drop the art made with them too, so it is
/// made again.
fn adjusted_image(&mut self, key: &str, derived: String, hash: u64, map: &vectorcraft_effects::ColorMap) {
if self.adjusts.contains_key(&derived) {
return;
}
if self.adjusts.len() >= 1024 {
self.adjusts.clear();
self.fx_arts.clear();
}
self.adjusts.insert(derived, (key.to_string(), hash, map.clone()));
}
/// The pixels of image `key` (a blob of `doc`, or a recoloured one named by
/// [`Self::adjusted_image`]), or a greyscale copy of them, with their key in the shared store.
/// `touch` marks them used there.
fn image_pixmap(&mut self, doc: &Document, key: &str, grey: bool, touch: bool) -> Option<(images::ImageKey, Arc<Pixmap>)> {
let (key, colour) = match doc.images.get(key) {
Some(blob) => blob_pixmap(blob, touch)?,
None => {
let (src, hash, map) = self.adjusts.get(key)?.clone();
let (src_key, src) = self.image_pixmap(doc, &src, false, touch)?;
let key = images::ImageKey { adjust: Some(hash), ..src_key };
match lookup(&key, touch) {
Some(pm) => (key, pm),
None => {
let mut pm = (*src).clone();
let mut memo: HashMap<[u8; 4], [u8; 3]> = HashMap::new();
for px in pm.data_mut().iter_mut().filter(|p| p.a > 0) {
let a = px.a as u32;
let [r, g, b] = *memo.entry([px.r, px.g, px.b, px.a]).or_insert_with(|| {
// Premultiplied to straight, adjusted, then premultiplied again.
let straight = [px.r, px.g, px.b].map(|v| ((v as u32 * 255 + a / 2) / a).min(255) as u8);
map.apply_rgb8(straight).map(|v| ((v as u32 * a + 127) / 255) as u8)
});
(px.r, px.g, px.b) = (r, g, b);
}
let pm = images::store().insert(key.clone(), Arc::new(pm));
(key, pm)
}
}
}
};
if !grey {
return Some((key, colour));
}
let key = images::ImageKey { grey: true, ..key };
if let Some(p) = lookup(&key, touch) {
return Some((key, p));
}
let mut pm = (*colour).clone();
for px in pm.data_mut() {
// The luma of premultiplied components is the premultiplied luma.
let l = (0.2126 * px.r as f32 + 0.7152 * px.g as f32 + 0.0722 * px.b as f32 + 0.5) as u8;
(px.r, px.g, px.b) = (l, l, l);
}
let pm = images::store().insert(key.clone(), Arc::new(pm));
Some((key, pm))
}
}
/// `key`'s pixels in the shared image store; `touch` marks them used.
fn lookup(key: &images::ImageKey, touch: bool) -> Option<Arc<Pixmap>> {
let mut s = images::store();
if touch { s.get(key) } else { s.peek(key) }
}
/// The decoded pixels of `blob` (a CMYK image's inks shown through the working CMYK profile, again
/// when the colour settings change), from the shared store, and their key there.
fn blob_pixmap(blob: &vectorcraft_doc::ImageBlob, touch: bool) -> Option<(images::ImageKey, Arc<Pixmap>)> {
let id = images::blob_id(blob);
let lut = id.cmyk.then(ink::display_lut);
let key =
images::ImageKey { content: id.content, adjust: None, grey: false, ink: Ink::Display, luts: (lut.as_ref().map_or(0, |l| l.1), 0), mip: 0 };
if let Some(pm) = lookup(&key, touch) {
return Some((key, pm));
}
// Decoded outside the lock: other renderers carry on meanwhile.
let pm = match lut.and_then(|(lut, _)| ink::cmyk_pixmap(blob, &lut)) {
Some(pm) => pm,
None => paint::decode_pixmap(&blob.bytes)?,
};
let pm = images::store().insert(key.clone(), Arc::new(pm));
Some((key, pm))
}
/// Image pixels `pm` (stored as `key`) to draw through `m` (unit square → device pixels), shrunk
/// by halves while still at least as fine as the device pixels ([`images::mip_level`]); the
/// pixels drawn are marked used in the store.
fn image_mip(key: &images::ImageKey, pm: Arc<Pixmap>, m: Affine) -> Arc<Pixmap> {
let (w, h) = (pm.width(), pm.height());
let level = images::mip_level(m * Affine::scale_non_uniform(1.0 / w.max(1) as f64, 1.0 / h.max(1) as f64), w, h);
let mut s = images::store();
if level == 0 {
let _ = s.get(key); // Marks the full pixels used (they were only looked at).
return pm;
}
let at = |l: u8| images::ImageKey { mip: l, ..key.clone() };
if let Some(p) = s.get(&at(level)) {
return p;
}
// From the finest level kept below it.
let (mut l, mut cur) = (1..level).rev().find_map(|l| s.peek(&at(l)).map(|p| (l, p))).unwrap_or((0, pm));
drop(s);
while l < level {
l += 1;
let half = Arc::new(images::half(&cur));
cur = images::store().insert(at(l), half);
}
cur
}
/// Can image node `n` (`im`) clipped by `region` be painted straight into the region: visible,
/// with no transparency, effects or paint of its own, and the region inside the image's frame
/// (within a pixel), so no paint past the image's edge shows?
fn plain_image(n: &Node, im: &vectorcraft_doc::ImageObject, region: &BezPath) -> bool {
if !(n.visible && n.has_default_transparency() && n.appearance.items.is_empty() && n.appearance.effects.is_empty()) {
return false;
}
let det = im.xf.determinant();
if !(det.abs() > 1e-12 && det.is_finite()) {
return false;
}
let b = im.xf.inverse().transform_rect_bbox(region.bounding_box());
b.x0 >= -1.0 && b.y0 >= -1.0 && b.x1 <= im.width as f64 + 1.0 && b.y1 <= im.height as f64 + 1.0
}
/// Opacity-mask coverage of one premultiplied pixel: luminance, with the area outside the mask
/// art black (clip) or white (no clip), optionally inverted.
fn mask_value(r: u8, g: u8, b: u8, a: u8, clip: bool, invert: bool) -> u8 {
let [kr, kg, kb] = vectorcraft_color::blend::MASK_LUM;
let mut l = (kr * r as f32 + kg * g as f32 + kb * b as f32) / 255.0;
if !clip {
l += 1.0 - a as f32 / 255.0;
}
if invert {
l = 1.0 - l;
}
(l.clamp(0.0, 1.0) * 255.0 + 0.5) as u8
}
/// Most pixels from the origin that a shape handed to the rasterizer may reach, or a stroke be
/// wide. A document's art stays within 4 million points, 2.6 billion pixels at the closest zoom.
/// Geometry far beyond that (from a malformed file) makes vello_cpu panic on a worker thread or ask
/// for more memory than there is, either of which ends the app: it is left undrawn.
const MAX_DEVICE: f64 = 1e10;
/// Can shape `bp` of `n` be drawn through `view`: its control points (finite) within
/// [`MAX_DEVICE`] pixels, and its visible strokes no wider?
fn drawable(bp: &BezPath, view: Affine, n: &Node) -> bool {
let b = view.transform_rect_bbox(bp.control_box());
let scale = view.determinant().abs().sqrt();
let widest = n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(st) = i { Some(st.width) } else { None }).fold(0.0, f64::max);
[b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_DEVICE) && (widest * scale).is_finite() && widest * scale <= MAX_DEVICE
}
/// A render context on the calling thread. vello_cpu's `RenderContext::new` defaults to a
/// multithreaded dispatcher, which panics on filter effects (glows, shadows, blur).
pub(crate) fn single_threaded_context(w: u16, h: u16) -> RenderContext {
RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() })
}
/// Exactly one visible painted item, a fill with its own Normal blend and full opacity (one
/// paint operation, so an object blend mode can be applied per draw).
fn single_plain_fill(n: &Node) -> bool {
let mut painted = n.appearance.items.iter().filter(|i| match i {
AppearanceItem::Fill(f) => f.visible && !f.paint.is_none(),
AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0,
});
matches!((painted.next(), painted.next()), (Some(AppearanceItem::Fill(f)), None) if f.blend == vectorcraft_color::BlendMode::Normal && f.opacity >= 1.0 && !matches!(f.paint, vectorcraft_color::Paint::Pattern { .. }))
}
/// Number of visible painted fill/stroke items (opacity folding is exact only for one).
fn painted_items(n: &Node) -> usize {
n.appearance
.items
.iter()
.filter(|i| match i {
AppearanceItem::Fill(f) => f.visible && !f.paint.is_none() && matches!(f.paint, vectorcraft_color::Paint::Solid { .. }),
AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0 && matches!(s.paint, vectorcraft_color::Paint::Solid { .. }) && s.dash.is_none(),
})
.count()
.max(if n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.visible && !matches!(f.paint, vectorcraft_color::Paint::Solid { .. } | vectorcraft_color::Paint::None)) || matches!(i, AppearanceItem::Stroke(s) if s.visible && !matches!(s.paint, vectorcraft_color::Paint::Solid { .. } | vectorcraft_color::Paint::None))) { 2 } else { 0 })
}
/// Preferred rasterizer thread count set by the app (Preferences → Performance); negative = automatic.
static THREADS_OVERRIDE: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1);
/// Override the worker-thread count used by renderers created from now on (`None` = automatic).
pub fn set_default_threads(n: Option<u16>) {
THREADS_OVERRIDE.store(n.map_or(-1, i32::from), std::sync::atomic::Ordering::Relaxed);
}
/// Rasterizer worker threads: 0 on wasm; otherwise one per core up to 8, leaving a core free. On a
/// 10-core machine 8 workers render a 50,000-path page 1.4× faster than 4 (9 are slower again);
/// the pixels don't depend on the count (`pixels_do_not_depend_on_the_thread_count`).
pub fn default_threads() -> u16 {
#[cfg(target_arch = "wasm32")]
{
0
}
#[cfg(not(target_arch = "wasm32"))]
{
let o = THREADS_OVERRIDE.load(std::sync::atomic::Ordering::Relaxed);
if o >= 0 {
return o.min(64) as u16;
}
if std::env::var_os("VECTORCRAFT_RENDER_THREADS").is_some() {
return std::env::var("VECTORCRAFT_RENDER_THREADS").ok().and_then(|v| v.parse().ok()).unwrap_or(0);
}
std::thread::available_parallelism().map(|n| (n.get().saturating_sub(1)).min(8) as u16).unwrap_or(0)
}
}
/// The whole text's glyph outlines as one path, and which of them each run draws.
struct TextGeom {
all: BezPath,
/// Spans of `all`'s elements drawn by a run, in order: (run, start, end). Outlines of runs
/// the text doesn't have are in `all` only.
spans: Vec<(usize, usize, usize)>,
bounds: Rect,
/// The boxes of characters whose font is missing (drawn in a substitute), and of glyphs the
/// font lacks (drawn from a fallback font): Document Setup's substitution highlights.
substituted_fonts: BezPath,
substituted_glyphs: BezPath,
/// Inline graphics placed by the layout.
inlines: Vec<vectorcraft_text::InlineGlyph>,
}
impl TextGeom {
/// The outlines run `i` draws: the glyphs of `all` it has, in their order. Kept once rather
/// than per run as well, which held every outline twice.
fn run(&self, i: usize) -> std::borrow::Cow<'_, BezPath> {
let els = self.all.elements();
if let [(r, 0, end)] = self.spans[..]
&& r == i
&& end == els.len()
{
return std::borrow::Cow::Borrowed(&self.all);
}
let mut p = BezPath::new();
for &(_, start, end) in self.spans.iter().filter(|s| s.0 == i) {
p.extend(els.get(start..end).unwrap_or_default().iter().copied());
}
std::borrow::Cow::Owned(p)
}
/// Approximate heap bytes, for the cache's budget.
fn bytes(&self) -> usize {
size_of::<Self>()
+ [&self.all, &self.substituted_fonts, &self.substituted_glyphs].into_iter().map(lru::path_bytes).sum::<usize>()
+ self.spans.len() * size_of::<(usize, usize, usize)>()
+ self.inlines.len() * size_of::<vectorcraft_text::InlineGlyph>()
}
}
fn text_geom(t: &TextObject) -> TextGeom {
text_geom_snapped(t, None)
}
/// Glyph geometry of `t`; `snap` (text space → device pixels) puts the glyphs on whole pixels.
fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom {
let db = vectorcraft_text::FontDb::global();
let mut layout = vectorcraft_text::layout(db, t);
if let Some(xf) = snap {
layout.snap_to_pixels(xf);
}
let mut all = BezPath::new();
let mut spans: Vec<(usize, usize, usize)> = vec![];
// Append `outline` to `all`, drawn by `run` when the text has that run.
let mut push = |all: &mut BezPath, run: usize, outline: &BezPath| {
let start = all.elements().len();
all.extend(outline.iter());
let end = all.elements().len();
if run >= t.runs.len() || start == end {
return;
}
match spans.last_mut() {
Some((r, _, e)) if *r == run && *e == start => *e = end,
_ => spans.push((run, start, end)),
}
};
// Per run: is its family missing, and the face its glyphs should come from.
let faces: Vec<(bool, Option<u32>)> = t
.runs
.iter()
.map(|r| match db.resolve(&r.style.font_family, &r.style.font_style) {
// The version the type names, when it's installed (see `FontDb::face_version`).
Some((f, m)) => {
let face = db.face_version(&r.style.font_family, &r.style.font_style, r.style.font_version.as_deref()).unwrap_or(f);
(m == vectorcraft_text::FontMatch::Missing, Some(face.id()))
}
None => (true, None),
})
.collect();
let (mut substituted_fonts, mut substituted_glyphs) = (BezPath::new(), BezPath::new());
for g in &layout.glyphs {
push(&mut all, g.run, &g.outline);
let Some(&(missing, face)) = faces.get(g.run) else { continue };
let target = if missing {
&mut substituted_fonts
} else if face.is_some_and(|f| f != g.font_id) {
&mut substituted_glyphs
} else {
continue;
};
let Some(line) = layout.lines.get(g.line) else { continue };
let mut cell = Rect::new(g.origin.x, g.origin.y - line.ascent, g.origin.x + g.advance, g.origin.y + line.descent).to_path(0.1);
cell.apply_affine(Affine::rotate_about(g.angle, g.origin));
target.extend(cell.iter());
}
// Underline and strikethrough bars, painted as their run's type is (#847).
for (run, bar) in vectorcraft_text::decorations(&layout, db, t) {
push(&mut all, run, &bar);
}
TextGeom { all, spans, bounds: layout.bounds, substituted_fonts, substituted_glyphs, inlines: layout.inlines }
}
/// Document Setup's highlight behind substituted fonts and glyphs (screen only).
const SUBSTITUTED: peniko::Color = peniko::Color::from_rgba8(255, 120, 190, 110);
/// The pixel size of `region` rendered at `scale` pixels per point (at least 1×1).
pub fn region_pixels(region: Rect, scale: f64) -> (u32, u32) {
((region.width() * scale).round().max(1.0) as u32, (region.height() * scale).round().max(1.0) as u32)
}
fn rects_overlap(a: Rect, b: Rect) -> bool {
a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1
}
fn fill_rule(r: FillRule) -> peniko::Fill {
match r {
FillRule::NonZero => peniko::Fill::NonZero,
FillRule::EvenOdd => peniko::Fill::EvenOdd,
}
}
pub(crate) fn blend_mode(b: vectorcraft_color::BlendMode) -> BlendMode {
use vectorcraft_color::BlendMode as B;
let mix = match b {
B::Normal => Mix::Normal,
B::Darken => Mix::Darken,
B::Multiply => Mix::Multiply,
B::ColorBurn => Mix::ColorBurn,
B::Lighten => Mix::Lighten,
B::Screen => Mix::Screen,
B::ColorDodge => Mix::ColorDodge,
B::Overlay => Mix::Overlay,
B::SoftLight => Mix::SoftLight,
B::HardLight => Mix::HardLight,
B::Difference => Mix::Difference,
B::Exclusion => Mix::Exclusion,
B::Hue => Mix::Hue,
B::Saturation => Mix::Saturation,
B::Color => Mix::Color,
B::Luminosity => Mix::Luminosity,
};
BlendMode::new(mix, Compose::SrcOver)
}
#[cfg(not(target_arch = "wasm32"))]
fn now() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_micros() as u64).unwrap_or(0)
}
#[cfg(target_arch = "wasm32")]
fn now() -> u64 {
0
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_adjust;
#[cfg(test)]
mod tests_blend;
#[cfg(test)]
mod tests_charstroke;
#[cfg(test)]
mod tests_clip;
#[cfg(test)]
mod tests_cmykblend;
#[cfg(test)]
mod tests_container;
#[cfg(test)]
mod tests_fontchange;
#[cfg(test)]
mod tests_freeform;
#[cfg(test)]
mod tests_fxzoom;
#[cfg(test)]
mod tests_isolation;
#[cfg(test)]
mod tests_knockout;
#[cfg(test)]
mod tests_layeropts;
#[cfg(test)]
mod tests_memory;
#[cfg(test)]
mod tests_objectfx;
#[cfg(test)]
mod tests_placed_document;
#[cfg(test)]
mod tests_setup;
#[cfg(test)]
mod tests_strokegradient;
#[cfg(test)]
mod tests_tileedge;