mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 19:27:21 -06:00
object.flattenTransparency {preset?|options, ids?} splits the targets into groups of overlapping
objects. Groups with transparency become atomic regions (faces of the arrangement of every fill,
outlined stroke and clipping path) filled with the colour the renderer composites there: blend
modes, group opacity, isolated and non-isolated groups, knockout groups and knockout shapes, over
white or (Preserve Alpha) over nothing. Regions reached by gradients, patterns, images, opacity
masks or raster effects are rendered into one image per group, under the regions, clipped to
them with Clip Complex Regions. The raster/vector balance rasterizes whole groups past its region
budget (all at 0). Options and presets live in FlattenOptions; single visible paints keep their
colour and overprint; text and strokes can be outlined everywhere. One undo step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
198 lines
7.9 KiB
Rust
198 lines
7.9 KiB
Rust
//! Brushed strokes and symbol-instance staining in the renderer.
|
|
//!
|
|
//! A stroke with `brush: Some(name)` is painted as the filled art `vectorcraft_brush::stroke_pieces`
|
|
//! generates. Pieces are cached per (node, stroke item) and validated by the node's path, the
|
|
//! stroke layer and the brush definition (an `Arc` from the per-frame parsed library), so edits to
|
|
//! any of them regenerate the art and nothing else does.
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
|
|
use vectorcraft_brush::Brush;
|
|
use vectorcraft_doc::{AppearanceItem, Document, Node, StrokeLayer};
|
|
use vectorcraft_geom::{BezPath, Rect};
|
|
use vello_cpu::RenderContext;
|
|
|
|
use crate::{Frame, Renderer};
|
|
|
|
struct Entry {
|
|
path: BezPath,
|
|
stroke: StrokeLayer,
|
|
brush: Arc<Brush>,
|
|
pieces: Arc<Vec<Node>>,
|
|
stamp: u64,
|
|
}
|
|
|
|
/// Parsed brush library and generated brush art.
|
|
#[derive(Default)]
|
|
pub(crate) struct BrushCache {
|
|
/// The document value the library was parsed from (`None` = defaults).
|
|
src: Option<Option<vectorcraft_brush::serde_json::Value>>,
|
|
/// (frame stamp, document address) the library was last validated for.
|
|
checked: (u64, usize),
|
|
lib: HashMap<String, Arc<Brush>>,
|
|
entries: HashMap<(u64, usize), Entry>,
|
|
}
|
|
|
|
impl BrushCache {
|
|
fn library(&mut self, doc: &Document, stamp: u64) {
|
|
let key = (stamp, doc as *const Document as usize);
|
|
if self.checked == key {
|
|
return;
|
|
}
|
|
self.checked = key;
|
|
let cur = doc.unknown.get(vectorcraft_brush::DOC_KEY);
|
|
if self.src.as_ref().is_some_and(|s| s.as_ref() == cur) {
|
|
return;
|
|
}
|
|
self.src = Some(cur.cloned());
|
|
self.lib = vectorcraft_brush::library(doc).into_iter().map(|b| (b.name.clone(), Arc::new(b))).collect();
|
|
}
|
|
}
|
|
|
|
/// Cull bounds that allow for brush art reaching past the stroke (scatter, wide nibs).
|
|
pub(crate) fn cull_bounds(n: &Node) -> Option<Rect> {
|
|
let b = crate::fx::cull_bounds(n)?;
|
|
if !vectorcraft_brush::has_brush(n) {
|
|
return Some(b);
|
|
}
|
|
let w = n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.width) } else { None }).fold(1.0, f64::max);
|
|
let pad = 16.0 * w + 8.0;
|
|
Some(b.inflate(pad, pad))
|
|
}
|
|
|
|
/// A symbol's art for instance `inst`, stained by the instance's fill (Symbol Stainer: the fill
|
|
/// colour mixed in by the fill layer's opacity).
|
|
pub fn instance_art(art: &Node, inst: &Node) -> Node {
|
|
let mut a = art.clone();
|
|
if let Some(f) = inst.appearance.fill()
|
|
&& f.visible
|
|
&& let Some(c) = f.paint.color()
|
|
{
|
|
vectorcraft_brush::tint_node(&mut a, &c, f.opacity);
|
|
}
|
|
a
|
|
}
|
|
|
|
impl Renderer {
|
|
/// Paint stroke `st` of `n` (outline `bp`) with its brush. Returns false when the brush is
|
|
/// unknown (the caller then draws a plain stroke).
|
|
pub(crate) fn draw_brush(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, bp: &BezPath, st: &StrokeLayer) -> bool {
|
|
let Some(name) = st.brush.as_deref() else { return false };
|
|
self.brushes.library(f.doc, self.stamp);
|
|
let Some(brush) = self.brushes.lib.get(name).cloned() else { return false };
|
|
let idx = n.appearance.items.iter().position(|i| matches!(i, AppearanceItem::Stroke(s) if std::ptr::eq(s, st))).unwrap_or(usize::MAX);
|
|
let key = (n.id.0, idx);
|
|
let stamp = self.stamp;
|
|
let pieces = match self.brushes.entries.get_mut(&key) {
|
|
Some(e) if Arc::ptr_eq(&e.brush, &brush) && e.stroke == *st && e.path == *bp => {
|
|
e.stamp = stamp;
|
|
e.pieces.clone()
|
|
}
|
|
_ => {
|
|
let pieces = Arc::new(vectorcraft_brush::stroke_pieces(&brush, bp, st));
|
|
if self.brushes.entries.len() > 2048 {
|
|
self.brushes.entries.retain(|_, e| stamp.saturating_sub(e.stamp) <= 3);
|
|
}
|
|
self.brushes.entries.insert(key, Entry { path: bp.clone(), stroke: st.clone(), brush, pieces: pieces.clone(), stamp });
|
|
pieces
|
|
}
|
|
};
|
|
let mut draw = |r: &mut Self, c: &mut RenderContext, fr: &Frame| {
|
|
for p in pieces.iter() {
|
|
r.draw_node(c, fr, p, true);
|
|
}
|
|
};
|
|
if st.opacity < 1.0 || st.blend != vectorcraft_color::BlendMode::Normal {
|
|
self.group(ctx, f, crate::group::Composite { blend: st.blend, opacity: st.opacity, ..Default::default() }, &mut draw);
|
|
} else {
|
|
draw(self, ctx, f);
|
|
}
|
|
true
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use vectorcraft_color::{Color, Paint};
|
|
use vectorcraft_doc::{Appearance, Document, Node};
|
|
use vectorcraft_geom::{Affine, Rect, shapes};
|
|
|
|
use crate::{RenderOptions, Rendered, Renderer};
|
|
|
|
fn doc_with_line(brush: Option<&str>, width: f64) -> Document {
|
|
let mut d = Document::new(200.0, 100.0);
|
|
let id = d.alloc_id();
|
|
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), width);
|
|
ap.stroke_mut().unwrap().brush = brush.map(str::to_string);
|
|
let n = Node::path(id, shapes::line((20.0, 50.0).into(), (180.0, 50.0).into()), ap);
|
|
let l = d.layers[0].id;
|
|
d.insert(Some(l), 0, n).unwrap();
|
|
d
|
|
}
|
|
|
|
fn render(r: &mut Renderer, d: &Document) -> Rendered {
|
|
r.render(d, 200, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
|
|
}
|
|
|
|
fn dark(p: [u8; 4]) -> bool {
|
|
(p[0] as u32 + p[1] as u32 + p[2] as u32) < 200
|
|
}
|
|
|
|
#[test]
|
|
fn calligraphic_brush_paints_wider_than_the_stroke() {
|
|
let mut r = Renderer::new();
|
|
let plain = render(&mut r, &doc_with_line(None, 1.0));
|
|
assert!(!dark(plain.pixel(100, 53)));
|
|
let d = doc_with_line(Some("10 pt. Oval"), 1.0);
|
|
let img = render(&mut r, &d);
|
|
assert!(dark(img.pixel(100, 50)) && dark(img.pixel(100, 52)), "brush art is painted: {:?}", img.pixel(100, 52));
|
|
assert!(!dark(img.pixel(100, 60)));
|
|
// Second frame hits the cache and paints the same.
|
|
let again = render(&mut r, &d);
|
|
assert_eq!(again.pixels, img.pixels);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_brush_falls_back_to_plain_stroke_and_scatter_renders() {
|
|
let mut r = Renderer::new();
|
|
let img = render(&mut r, &doc_with_line(Some("No Such Brush"), 2.0));
|
|
assert!(dark(img.pixel(100, 50)));
|
|
let img = render(&mut r, &doc_with_line(Some("Dots"), 1.0));
|
|
let dark_px = (20..180).filter(|x| dark(img.pixel(*x, 50))).count();
|
|
assert!(dark_px > 20 && dark_px < 150, "dots leave gaps: {dark_px}");
|
|
}
|
|
|
|
#[test]
|
|
fn brush_definition_edits_rerender() {
|
|
let mut r = Renderer::new();
|
|
let mut d = doc_with_line(Some("3 pt. Round"), 1.0);
|
|
let a = render(&mut r, &d);
|
|
let mut lib = vectorcraft_brush::library(&d);
|
|
if let vectorcraft_brush::BrushKind::Calligraphic(c) = &mut lib[0].kind {
|
|
c.size = 20.0;
|
|
}
|
|
vectorcraft_brush::store(&mut d, &lib);
|
|
let b = render(&mut r, &d);
|
|
assert!(!dark(a.pixel(100, 58)) && dark(b.pixel(100, 58)));
|
|
}
|
|
|
|
#[test]
|
|
fn stained_symbol_instance() {
|
|
let mut d = Document::new(100.0, 100.0);
|
|
let art = Node::path(
|
|
vectorcraft_doc::NodeId(0),
|
|
shapes::rectangle(Rect::new(-10.0, -10.0, 10.0, 10.0)),
|
|
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
|
|
);
|
|
d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: std::sync::Arc::new(art) });
|
|
let id = d.alloc_id();
|
|
let mut inst = Node::new(id, vectorcraft_doc::NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((50.0, 50.0)) });
|
|
inst.appearance.set_fill(Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
|
|
let l = d.layers[0].id;
|
|
d.insert(Some(l), 0, inst).unwrap();
|
|
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default());
|
|
assert_eq!(img.pixel(50, 50), [255, 0, 0, 255]);
|
|
}
|
|
}
|