The Gradient panel's Stroke buttons now work: with the Stroke proxy on a
linear or radial gradient they lay it within the stroke (placed on the
page, as before), along the path (start to end of each subpath) or across
it (left edge to right edge; inside/outside strokes span their visible
side). `paint.editGradient {stroke: true, strokeMode}` sets it (new art
too with nothing selected); `document.inspect` reports
strokeOptions.gradientMode. Model: StrokeLayer.gradient_mode (default
Within, not written).
Shared geometry in effects::stroke::gradient: ribs on the bisectors of
the flattened path (fans round sharp corners, widened under arrowheads,
following width profiles) cut slices each painted with a linear piece of
the gradient (Gradient::span). The canvas and raster export clip them to
the stroke's outline and arrowheads, grown a pixel and composited with
copy so there are no seams and translucent stops paint once. SVG and PDF
write the same slices clipped to the outline, with a warning (new
vectorcraft_svg::export_with_report). Outline Stroke and Expand make
gradient meshes clipped to the outline. Mesh quads now grow along their
edges so thin quads show no seams. Dashes, profiles and arrowheads keep
working in both modes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
190 lines
7.1 KiB
Rust
190 lines
7.1 KiB
Rust
//! Live objects in the renderer.
|
|
//!
|
|
//! Blends and envelopes are evaluated by `vectorcraft_doc::live` and the resulting objects cached
|
|
//! by `Arc` identity of the live node (so the steps keep stable `Arc`s and hit the geometry cache
|
|
//! frame after frame). Gradient meshes are tessellated into many small solid-colour quads (vello
|
|
//! has no mesh shading), with the density chosen from the on-screen patch size; each quad is
|
|
//! grown by half a device pixel along its edges so neighbours overlap and no antialiasing seams
|
|
//! show.
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
|
|
use vectorcraft_doc::live::{self, GradientMesh, MeshQuad};
|
|
use vectorcraft_doc::{Node, NodeKind};
|
|
use vectorcraft_geom::{BezPath, PathData, Vec2};
|
|
use vello_cpu::RenderContext;
|
|
use vello_cpu::peniko;
|
|
|
|
use crate::{Frame, Renderer, text_geom};
|
|
|
|
type Expanded = Arc<Vec<Arc<Node>>>;
|
|
type MeshEntry = (Arc<Node>, Arc<Vec<MeshQuad>>, u64);
|
|
|
|
/// Per-renderer cache of evaluated live objects.
|
|
#[derive(Default)]
|
|
pub(crate) struct LiveCache {
|
|
expanded: HashMap<usize, (Arc<Node>, Expanded, u64)>,
|
|
meshes: HashMap<(usize, usize), MeshEntry>,
|
|
stamp: u64,
|
|
}
|
|
|
|
impl LiveCache {
|
|
fn tick(&mut self, stamp: u64) {
|
|
self.stamp = stamp;
|
|
if self.expanded.len() > 256 {
|
|
self.expanded.retain(|_, e| stamp - e.2 <= 3);
|
|
}
|
|
if self.meshes.len() > 256 {
|
|
self.meshes.retain(|_, e| stamp - e.2 <= 3);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Text converted to one outline path (for envelopes): glyphs in document space, painted with the
|
|
/// first run's fill/stroke.
|
|
pub(crate) fn outline_text(n: &Node) -> Option<Node> {
|
|
let NodeKind::Text(t) = &n.kind else { return None };
|
|
let g = text_geom(t);
|
|
let mut bp = g.all.clone();
|
|
bp.apply_affine(t.xf);
|
|
let mut out = Node::path(n.id, PathData::from_bezpath(&bp), n.appearance.clone());
|
|
if out.appearance.items.is_empty()
|
|
&& let Some(r) = t.runs.first()
|
|
{
|
|
out.appearance = r.style.appearance();
|
|
}
|
|
out.opacity = n.opacity;
|
|
out.blend = n.blend;
|
|
Some(out)
|
|
}
|
|
|
|
/// Evaluate a live node one level (blend steps / envelope result, text outlined; mesh → flat
|
|
/// pieces). Non-live nodes return themselves.
|
|
pub fn expand_live(n: &Node) -> Vec<Node> {
|
|
let hook: &dyn Fn(&Node) -> Option<Node> = &outline_text;
|
|
live::expand_live_with(n, Some(hook))
|
|
}
|
|
|
|
/// Subdivisions per patch for a patch about `size_px` device pixels across.
|
|
fn mesh_level(m: &GradientMesh, bounds_px: f64) -> usize {
|
|
let per = bounds_px / (m.rows.max(m.cols).max(1) as f64);
|
|
((per / 5.0).ceil() as usize).clamp(2, 32)
|
|
}
|
|
|
|
impl Renderer {
|
|
fn live_expanded(&mut self, a: &Arc<Node>, cache: bool) -> Expanded {
|
|
let key = Arc::as_ptr(a) as usize;
|
|
if cache
|
|
&& let Some(e) = self.live.expanded.get_mut(&key)
|
|
&& Arc::ptr_eq(&e.0, a)
|
|
{
|
|
e.2 = self.live.stamp;
|
|
return e.1.clone();
|
|
}
|
|
let hook: &dyn Fn(&Node) -> Option<Node> = &outline_text;
|
|
let v: Expanded = match crate::effects::pathfinder_children(a, Some(hook)) {
|
|
Some(children) => Arc::new(children),
|
|
None => Arc::new(expand_live(a).into_iter().map(Arc::new).collect()),
|
|
};
|
|
if cache {
|
|
self.live.expanded.insert(key, (a.clone(), v.clone(), self.live.stamp));
|
|
}
|
|
v
|
|
}
|
|
|
|
fn mesh_quads(&mut self, a: &Arc<Node>, m: &GradientMesh, level: usize, cache: bool) -> Arc<Vec<MeshQuad>> {
|
|
let key = (Arc::as_ptr(a) as usize, level);
|
|
if cache
|
|
&& let Some(e) = self.live.meshes.get_mut(&key)
|
|
&& Arc::ptr_eq(&e.0, a)
|
|
{
|
|
e.2 = self.live.stamp;
|
|
return e.1.clone();
|
|
}
|
|
let q = Arc::new(m.quads(level));
|
|
if cache {
|
|
self.live.meshes.insert(key, (a.clone(), q.clone(), self.live.stamp));
|
|
}
|
|
q
|
|
}
|
|
|
|
/// Draw a live node reached through the tree walk (handles its transparency group).
|
|
pub(crate) fn draw_live(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>) {
|
|
let stamp = self.stamp;
|
|
self.live.tick(stamp);
|
|
let opacity = self.opacity_of(a);
|
|
if !f.opts.outline && (opacity < 1.0 || a.blend != vectorcraft_color::BlendMode::Normal || a.isolate) {
|
|
let blends = self.blends_through(a);
|
|
let bounds = if blends { self.bounds_of(a) } else { None };
|
|
let comp = crate::group::Composite { blend: a.blend, opacity, isolated: a.isolate, blends, bounds, ..Default::default() };
|
|
self.group(ctx, f, comp, &mut |r, c, fr| r.draw_live_body(c, fr, a, true));
|
|
} else {
|
|
self.draw_live_body(ctx, f, a, true);
|
|
}
|
|
self.stats.drawn += 1;
|
|
}
|
|
|
|
/// Draw the evaluated content of a live node (no transparency group).
|
|
pub(crate) fn draw_live_body(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>, cache: bool) {
|
|
match &a.kind {
|
|
NodeKind::Mesh(m) => self.draw_mesh(ctx, f, a, m, cache),
|
|
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) | NodeKind::Group { .. } | NodeKind::Layer { .. } => {
|
|
let items = self.live_expanded(a, cache);
|
|
for c in items.iter() {
|
|
self.draw_arc(ctx, f, c);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn draw_mesh(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>, m: &GradientMesh, cache: bool) {
|
|
if !m.is_valid() {
|
|
return;
|
|
}
|
|
if f.opts.outline {
|
|
let bp = m.lines().to_bezpath();
|
|
self.hairline(ctx, f, &bp, [0, 0, 0, 255]);
|
|
return;
|
|
}
|
|
let Some(b) = m.bounds() else { return };
|
|
let level = mesh_level(m, b.width().max(b.height()) / f.px.max(1e-12));
|
|
let quads = self.mesh_quads(a, m, level, cache);
|
|
ctx.set_transform(f.view);
|
|
ctx.set_fill_rule(peniko::Fill::NonZero);
|
|
let grow = f.px * 0.5;
|
|
let alpha = self.alpha;
|
|
for q in quads.iter() {
|
|
if q.opacity <= 0.0 {
|
|
continue;
|
|
}
|
|
let mut bp = BezPath::new();
|
|
for (i, p) in q.pts.iter().enumerate() {
|
|
// Out along both edges at the corner, so thin quads grow across too.
|
|
let d: Vec2 = [q.pts[(i + 3) % 4], q.pts[(i + 1) % 4]]
|
|
.iter()
|
|
.map(|n| *p - *n)
|
|
.filter(|e| e.hypot() > 1e-12)
|
|
.map(|e| e / e.hypot())
|
|
.fold(Vec2::ZERO, |a, e| a + e);
|
|
let p2 = *p + d * grow;
|
|
if i == 0 {
|
|
bp.move_to(p2);
|
|
} else {
|
|
bp.line_to(p2);
|
|
}
|
|
}
|
|
bp.close_path();
|
|
ctx.set_paint(f.ink.color(&q.color, q.opacity * alpha));
|
|
ctx.fill_path(&bp);
|
|
}
|
|
}
|
|
|
|
/// Draw a live node reached without its `Arc` (thumbnails, clip fallbacks): uncached.
|
|
pub(crate) fn draw_live_node(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node) {
|
|
let a = Arc::new(n.clone());
|
|
self.draw_live_body(ctx, f, &a, false);
|
|
}
|
|
}
|