M8.16: Envelopes bend images, symbols, gradients, patterns and appearance; warps keep their frame

- Images distort as a raster mesh warp: cut by Fidelity into triangles,
  each a clipped image under the affine map of its corners (pieces overlap
  slightly, no seams; the renderer paints them without clip layers; SVG
  writes the pixels once and <use>s them).
- Symbol instances distort as their symbol's art (the renderer's cache
  follows edits to the symbol).
- Distort Appearance bakes strokes (filled outlines) and geometry effects
  before bending; Distort Linear Gradients bends linear gradients; Distort
  Pattern Fills bends the tiles. Opacity masks of the content bend too.
- Bug fix: a rotated, sheared or reflected warp envelope re-warped along
  the page axes. Envelopes now keep their own frame (append-only model
  field), so the warp turns with the art; move and scale keep it square.
- Live evaluation shares one set of hooks (vectorcraft_doc::live::Hooks,
  vectorcraft_effects::{expand_live, expanded_live_group,
  expand_live_deep}) between the canvas, Expand and every exporter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-06 12:01:47 +07:00
co-authored by Claude Opus 5.5
parent cd99714b42
commit 04fa49413c
21 changed files with 1008 additions and 146 deletions
+44 -3
View File
@@ -834,6 +834,16 @@ impl Renderer {
&& 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() };
@@ -1264,6 +1274,12 @@ impl Renderer {
}
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.
@@ -1278,10 +1294,20 @@ impl Renderer {
let pm = if outline { pm } else { self.ink_image(&cache_key, &pm, f.ink) };
let sx = im.width as f64 / pm.width().max(1) as f64;
let sy = im.height as f64 / pm.height().max(1) as f64;
ctx.set_transform(f.view * im.xf);
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler: peniko::ImageSampler::default() });
ctx.set_paint_transform(Affine::scale_non_uniform(sx, sy));
ctx.fill_rect(&rect);
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();
}
if outline {
@@ -1352,6 +1378,21 @@ impl Renderer {
}
}
/// 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 {
+42 -12
View File
@@ -1,8 +1,10 @@
//! 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
//! Blends and envelopes are evaluated by `vectorcraft_doc::live` (through
//! `vectorcraft_effects::expand_live`, as the exporters do) and the resulting objects cached by
//! `Arc` identity of the live node and of the symbols and patterns it draws (so the steps keep
//! stable `Arc`s and hit the geometry cache frame after frame, and editing a symbol redraws an
//! envelope around an instance of it). 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.
@@ -11,7 +13,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use vectorcraft_doc::live::{GradientMesh, MeshQuad};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_doc::{Document, Node, NodeKind};
use vectorcraft_geom::{BezPath, Vec2};
use vello_cpu::RenderContext;
use vello_cpu::peniko;
@@ -24,7 +26,7 @@ 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)>,
expanded: HashMap<usize, (Arc<Node>, Expanded, u64, u64)>,
meshes: HashMap<(usize, usize), MeshEntry>,
stamp: u64,
}
@@ -42,8 +44,34 @@ impl LiveCache {
}
/// One level of evaluation of a live node (blend steps, envelope result with type outlined run by
/// run, mesh → flat pieces); the exporters evaluate them alike. Non-live nodes return themselves.
pub use crate::effects::expand_live;
/// run, mesh → flat pieces) without a document (symbol instances and pattern tiles in envelopes
/// stay as they are). Non-live nodes return themselves.
pub fn expand_live(n: &Node) -> Vec<Node> {
crate::effects::expand_live(None, n)
}
/// What an evaluated live node depends on besides itself: the art of the symbols and the patterns
/// it draws (their `Arc`s outlive document copies, so this changes only when one is edited).
fn resources_key(doc: &Document, n: &Node) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
n.walk(&mut |c| {
if let NodeKind::SymbolInstance { symbol, .. } = &c.kind
&& let Some(s) = doc.symbols.iter().find(|s| s.name == *symbol)
{
(Arc::as_ptr(&s.art) as usize).hash(&mut h);
}
for i in &c.appearance.items {
if let vectorcraft_color::Paint::Pattern { pattern, .. } = i.paint()
&& let Some(def) = doc.pattern(pattern)
{
def.art.iter().for_each(|a| (Arc::as_ptr(a) as usize).hash(&mut h));
[def.tile.x0, def.tile.y0, def.tile.x1, def.tile.y1].iter().for_each(|v| v.to_bits().hash(&mut h));
}
}
});
h.finish()
}
/// Subdivisions per patch for a patch about `size_px` device pixels across.
fn mesh_level(m: &GradientMesh, bounds_px: f64) -> usize {
@@ -52,21 +80,23 @@ fn mesh_level(m: &GradientMesh, bounds_px: f64) -> usize {
}
impl Renderer {
fn live_expanded(&mut self, a: &Arc<Node>, cache: bool) -> Expanded {
fn live_expanded(&mut self, doc: &Document, a: &Arc<Node>, cache: bool) -> Expanded {
let key = Arc::as_ptr(a) as usize;
let resources = resources_key(doc, a);
if cache
&& let Some(e) = self.live.expanded.get_mut(&key)
&& Arc::ptr_eq(&e.0, a)
&& e.3 == resources
{
e.2 = self.live.stamp;
return e.1.clone();
}
let v: Expanded = match crate::effects::pathfinder_children(a, crate::effects::text_outliner()) {
Some(children) => Arc::new(children),
None => Arc::new(expand_live(a).into_iter().map(Arc::new).collect()),
None => Arc::new(crate::effects::expand_live(Some(doc), a).into_iter().map(Arc::new).collect()),
};
if cache {
self.live.expanded.insert(key, (a.clone(), v.clone(), self.live.stamp));
self.live.expanded.insert(key, (a.clone(), v.clone(), self.live.stamp, resources));
}
v
}
@@ -96,7 +126,7 @@ impl Renderer {
let knockout = !f.opts.outline
&& matches!(a.kind, NodeKind::Blend { .. })
&& a.knockout.resolve(self.knockout)
&& vectorcraft_doc::live::steps_knockout_shows(&self.live_expanded(a, true));
&& vectorcraft_doc::live::steps_knockout_shows(&self.live_expanded(f.doc, a, true));
let enclosing = std::mem::replace(&mut self.knockout, knockout);
if !f.opts.outline && (opacity < 1.0 || a.blend != vectorcraft_color::BlendMode::Normal || a.isolate || knockout) {
let blends = self.blends_through(a);
@@ -115,7 +145,7 @@ impl Renderer {
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);
let items = self.live_expanded(f.doc, a, cache);
// The flag holds while this blend is drawn (see `draw_live`).
if self.knockout && matches!(a.kind, NodeKind::Blend { .. }) {
self.draw_knockout(ctx, f, &items);