mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 02:37:01 -06:00
Symbol instances export as one <symbol> def per symbol and a <use> per instance. An instance the def can't stand for exactly (stained by its fill, or a transform under which the canvas paints differently: scaling with strokes, effects or live objects, turning with effects or brushes, any move with pattern paints or unlinked masks) is written as its own art, now stained like the canvas and with its patterns and unlinked masks left on the page. Symbols nested in themselves stop at a fixed depth. New SVG option hiddenLayers keeps hidden layers as display:none groups; document.save (and Save As / Save a Copy) turns it on unless told otherwise, exports leave hidden layers out. Import shows display:none layer groups to usvg and marks them hidden, so they reopen hidden (groups a <use> refers to stay templates). File Info metadata goes through one Dublin Core list. The symbol stain helper moves to vectorcraft-brush so the renderer and the SVG writer share it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
187 lines
7.5 KiB
Rust
187 lines
7.5 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))
|
|
}
|
|
|
|
pub use vectorcraft_brush::instance_art;
|
|
|
|
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]);
|
|
}
|
|
}
|