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:
parent
cd99714b42
commit
04fa49413c
|
|
@ -316,7 +316,7 @@ impl<'a> Scene<'a> {
|
|||
}
|
||||
// Live blends, envelopes, meshes and repeats are written as their evaluated art.
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
|
||||
let g = vectorcraft_effects::expand_live_deep(n);
|
||||
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
|
||||
for c in g.children().into_iter().flatten() {
|
||||
self.node(c, false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -892,6 +892,30 @@ impl Appearance {
|
|||
g.transform(a);
|
||||
}
|
||||
}
|
||||
/// Envelope Options → Distort Linear Gradients: bend the linear gradients of an object whose
|
||||
/// geometric bounds are `bounds` with a warp, given its local affine approximation at a point
|
||||
/// (taken at the middle of each gradient's vector). Unplaced ones are fixed to their fit first;
|
||||
/// other gradients stay as they are.
|
||||
pub fn warp_linear_gradients(&mut self, bounds: vectorcraft_geom::Rect, near: &dyn Fn(vectorcraft_geom::Point) -> vectorcraft_geom::Affine) {
|
||||
for i in &mut self.items {
|
||||
let (g, b) = match i {
|
||||
AppearanceItem::Fill(FillLayer { paint: Paint::Gradient(g), .. }) => (g, bounds),
|
||||
AppearanceItem::Stroke(s) => {
|
||||
let b = s.paint_bounds(bounds);
|
||||
let Paint::Gradient(g) = &mut s.paint else { continue };
|
||||
(g, b)
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
if g.gradient.kind != vectorcraft_color::GradientKind::Linear {
|
||||
continue;
|
||||
}
|
||||
g.pin(b);
|
||||
if let Some(geom) = g.geom {
|
||||
g.transform(near(geom.start.midpoint(geom.end)));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Map placed gradients through a warp, given its local affine approximation at a point (taken
|
||||
/// at each gradient's centre: the start of a radial, the middle of a linear vector). Freeform
|
||||
/// points each follow the warp at their own position.
|
||||
|
|
|
|||
|
|
@ -451,7 +451,7 @@ pub fn lerp_path(a: &PathData, b: &PathData, t: f64) -> PathData {
|
|||
|
||||
/// Path data of a path or compound path node (compound children concatenated), its fill rule and
|
||||
/// the number of subpaths each compound member holds.
|
||||
fn node_path(n: &Node) -> Option<(PathData, FillRule, Option<Vec<usize>>)> {
|
||||
pub(crate) fn node_path(n: &Node) -> Option<(PathData, FillRule, Option<Vec<usize>>)> {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule, None)),
|
||||
NodeKind::Compound { children, rule } => {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ use vectorcraft_color::{Color, Paint};
|
|||
use vectorcraft_geom::{Affine, Anchor, BezPath, CubicBez, ParamCurve, PathData, Point, Rect, Shape, SubPath, Vec2};
|
||||
|
||||
use crate::appearance::{Appearance, AppearanceItem, FillLayer};
|
||||
use crate::node::{Node, NodeId, NodeKind};
|
||||
use crate::node::{ImageObject, Node, NodeId, NodeKind};
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
|
|
@ -539,9 +539,11 @@ impl Coons {
|
|||
Some(Self { top, right, bottom, left })
|
||||
}
|
||||
|
||||
/// The point at `u` along a side; past its ends the end segments go on straight (art an
|
||||
/// envelope bends can reach past the content's box: outlined strokes).
|
||||
fn sample(side: &[Point], u: f64) -> Point {
|
||||
let s = u.clamp(0.0, 1.0) * (side.len() - 1) as f64;
|
||||
let i = (s.floor() as usize).min(side.len() - 2);
|
||||
let s = u * (side.len() - 1) as f64;
|
||||
let i = (s.floor().max(0.0) as usize).min(side.len() - 2);
|
||||
side[i].lerp(side[i + 1], s - i as f64)
|
||||
}
|
||||
|
||||
|
|
@ -1023,7 +1025,7 @@ fn catmull(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2, t: f64) -> Vec2 {
|
|||
}
|
||||
|
||||
/// Point map from the unit square onto a point grid (tensor-product Catmull-Rom; linear
|
||||
/// extrapolation at the borders so an undistorted grid is the identity).
|
||||
/// extrapolation at the borders so an undistorted grid is the identity, also past the square).
|
||||
fn grid_eval(rows: usize, cols: usize, pts: &[Point], u: f64, v: f64) -> Point {
|
||||
let at = |r: i64, c: i64| -> Vec2 {
|
||||
let rr = r.clamp(0, rows as i64);
|
||||
|
|
@ -1043,9 +1045,9 @@ fn grid_eval(rows: usize, cols: usize, pts: &[Point], u: f64, v: f64) -> Point {
|
|||
}
|
||||
p
|
||||
};
|
||||
let su = (u.clamp(0.0, 1.0) * cols as f64).min(cols as f64 - 1e-9);
|
||||
let sv = (v.clamp(0.0, 1.0) * rows as f64).min(rows as f64 - 1e-9);
|
||||
let (ci, ri) = (su.floor() as i64, sv.floor() as i64);
|
||||
// Past the grid's edges the outer patches go on (art can reach past the content's box).
|
||||
let (su, sv) = (u * cols as f64, v * rows as f64);
|
||||
let (ci, ri) = ((su.floor() as i64).clamp(0, cols as i64 - 1), (sv.floor() as i64).clamp(0, rows as i64 - 1));
|
||||
let (fu, fv) = (su - ci as f64, sv - ri as f64);
|
||||
let mut rowsv = [Vec2::ZERO; 4];
|
||||
for (k, rv) in rowsv.iter_mut().enumerate() {
|
||||
|
|
@ -1055,113 +1057,416 @@ fn grid_eval(rows: usize, cols: usize, pts: &[Point], u: f64, v: f64) -> Point {
|
|||
catmull(rowsv[0], rowsv[1], rowsv[2], rowsv[3], fv).to_point()
|
||||
}
|
||||
|
||||
/// The envelope's point map for content whose bounding box is `src`.
|
||||
pub fn envelope_mapper<'a>(kind: &'a EnvelopeKind, src: Rect) -> Box<dyn Fn(Point) -> Point + 'a> {
|
||||
let w = src.width().max(1e-9);
|
||||
let h = src.height().max(1e-9);
|
||||
/// The surface (u, v ∈ [0,1]) an envelope of `kind` maps content onto, for content whose bounds in
|
||||
/// the envelope's own `frame` (its axes → the document) are `src`: a warp bends along the frame's
|
||||
/// axes; a mesh's points and a top object's path are in the document already.
|
||||
fn surface_of<'a>(kind: &'a EnvelopeKind, src: Rect, frame: Affine) -> Box<dyn Fn(f64, f64) -> Point + 'a> {
|
||||
let flat = move |u: f64, v: f64| frame * Point::new(src.x0 + u * src.width(), src.y0 + v * src.height());
|
||||
match kind {
|
||||
EnvelopeKind::Warp { style, bend, h: dh, v: dv, horizontal } => {
|
||||
let st = WarpStyle::from_id(style).unwrap_or(WarpStyle::Arc);
|
||||
let b = bend.clamp(-100.0, 100.0) / 100.0;
|
||||
let (dh, dv) = (dh.clamp(-100.0, 100.0) / 100.0, dv.clamp(-100.0, 100.0) / 100.0);
|
||||
let c = src.center();
|
||||
let (hw, hh) = (w / 2.0, h / 2.0);
|
||||
let (hw, hh) = (src.width() / 2.0, src.height() / 2.0);
|
||||
let horizontal = *horizontal;
|
||||
Box::new(move |q: Point| {
|
||||
let (x, y) = ((q.x - c.x) / hw, (q.y - c.y) / hh);
|
||||
Box::new(move |u: f64, v: f64| {
|
||||
let (x, y) = (2.0 * u - 1.0, 2.0 * v - 1.0);
|
||||
let (x2, y2) = if horizontal {
|
||||
warp_point(st, b, dh, dv, x, y)
|
||||
} else {
|
||||
let (a, b2) = warp_point(st, b, dh, dv, y, x);
|
||||
(b2, a)
|
||||
};
|
||||
Point::new(c.x + x2 * hw, c.y + y2 * hh)
|
||||
frame * Point::new(c.x + x2 * hw, c.y + y2 * hh)
|
||||
})
|
||||
}
|
||||
EnvelopeKind::Mesh { rows, cols, points } => {
|
||||
let (rows, cols) = (*rows as usize, *cols as usize);
|
||||
if rows == 0 || cols == 0 || points.len() != (rows + 1) * (cols + 1) {
|
||||
return Box::new(|q| q);
|
||||
return Box::new(flat);
|
||||
}
|
||||
Box::new(move |q: Point| grid_eval(rows, cols, points, (q.x - src.x0) / w, (q.y - src.y0) / h))
|
||||
Box::new(move |u: f64, v: f64| grid_eval(rows, cols, points, u, v))
|
||||
}
|
||||
EnvelopeKind::TopObject { path } => match Coons::from_path(path) {
|
||||
Some(co) => Box::new(move |q: Point| co.eval((q.x - src.x0) / w, (q.y - src.y0) / h)),
|
||||
None => Box::new(|q| q),
|
||||
Some(co) => Box::new(move |u: f64, v: f64| co.eval(u, v)),
|
||||
None => Box::new(flat),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The envelope's point map for content whose bounding box is `src` (page axes).
|
||||
pub fn envelope_mapper<'a>(kind: &'a EnvelopeKind, src: Rect) -> Box<dyn Fn(Point) -> Point + 'a> {
|
||||
let s = surface_of(kind, src, Affine::IDENTITY);
|
||||
let (w, h) = (src.width().max(1e-9), src.height().max(1e-9));
|
||||
Box::new(move |q: Point| s((q.x - src.x0) / w, (q.y - src.y0) / h))
|
||||
}
|
||||
|
||||
/// The frame an envelope keeps after transform `m` (its frame so far composed with the
|
||||
/// transform): identity when its axes stay square to the page, unflipped, since evaluating on
|
||||
/// the page axes gives the same result then.
|
||||
pub fn envelope_frame(m: Affine) -> Affine {
|
||||
let [a, b, c, d, _, _] = m.as_coeffs();
|
||||
if b.abs() < 1e-12 && c.abs() < 1e-12 && a > 0.0 && d > 0.0 { Affine::IDENTITY } else { m }
|
||||
}
|
||||
|
||||
/// An envelope's point map: content placed in the envelope's own frame maps, by its bounds there,
|
||||
/// onto the unit square and from there onto the envelope's surface ([`surface_of`]).
|
||||
pub struct EnvelopeMap<'a> {
|
||||
src: Rect,
|
||||
frame: Affine,
|
||||
to_frame: Affine,
|
||||
surface: Box<dyn Fn(f64, f64) -> Point + 'a>,
|
||||
}
|
||||
|
||||
impl<'a> EnvelopeMap<'a> {
|
||||
/// The map of an envelope of `kind` around `content` with `frame`. `None` when the content
|
||||
/// has no bounds.
|
||||
pub fn new(content: &[Arc<Node>], kind: &'a EnvelopeKind, frame: Affine) -> Option<Self> {
|
||||
let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY };
|
||||
let to_frame = frame.inverse();
|
||||
let src = if frame == Affine::IDENTITY {
|
||||
nodes_bounds(content)?
|
||||
} else {
|
||||
content.iter().try_fold(None, |acc, c| {
|
||||
let mut m = (**c).clone();
|
||||
m.transform(to_frame, false);
|
||||
Some(vectorcraft_geom::union_opt(acc, m.geometric_bounds()))
|
||||
})??
|
||||
};
|
||||
Some(Self { src, frame, to_frame, surface: surface_of(kind, src, frame) })
|
||||
}
|
||||
|
||||
/// The map of envelope node `n` (`None` for other nodes).
|
||||
pub fn of(n: &'a Node) -> Option<Self> {
|
||||
match &n.kind {
|
||||
NodeKind::Envelope { content, kind, frame, .. } => Self::new(content, kind, *frame),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The content's bounds in the envelope's frame.
|
||||
pub fn src(&self) -> Rect {
|
||||
self.src
|
||||
}
|
||||
|
||||
/// The surface point at (u, v) ∈ [0,1]².
|
||||
pub fn at(&self, u: f64, v: f64) -> Point {
|
||||
(self.surface)(u, v)
|
||||
}
|
||||
|
||||
/// Where the envelope puts document point `p`.
|
||||
pub fn map(&self, p: Point) -> Point {
|
||||
let q = self.to_frame * p;
|
||||
let (w, h) = (self.src.width().max(1e-9), self.src.height().max(1e-9));
|
||||
self.at((q.x - self.src.x0) / w, (q.y - self.src.y0) / h)
|
||||
}
|
||||
|
||||
/// A flat `(rows+1)×(cols+1)` grid over the content, in the envelope's frame (row-major).
|
||||
pub fn grid(&self, rows: u32, cols: u32) -> Vec<Point> {
|
||||
grid_points(self.src, rows, cols).into_iter().map(|p| self.frame * p).collect()
|
||||
}
|
||||
|
||||
/// The surface sampled on a `rows`×`cols` grid as a mesh whose handles follow it and whose
|
||||
/// points are `color`: Release gives it back as the envelope's shape, Reset with Mesh starts
|
||||
/// from it.
|
||||
pub fn surface_mesh(&self, rows: u32, cols: u32, color: Color) -> GradientMesh {
|
||||
GradientMesh::from_surface(rows, cols, &|u, v| self.at(u, v), &|_, _| color)
|
||||
}
|
||||
|
||||
/// The surface's bounds (sampled; a mesh's points and a top object's path bound it exactly).
|
||||
fn bounds(&self) -> Option<Rect> {
|
||||
const N: usize = 16;
|
||||
let mut r: Option<Rect> = None;
|
||||
for j in 0..=N {
|
||||
for i in 0..=N {
|
||||
let q = self.at(i as f64 / N as f64, j as f64 / N as f64);
|
||||
r = Some(r.map_or(Rect::from_points(q, q), |r| r.union_pt(q)));
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional hook that converts nodes the pure evaluation can't map (text) to outlines.
|
||||
pub type Outliner<'a> = Option<&'a dyn Fn(&Node) -> Option<Node>>;
|
||||
|
||||
fn map_node(n: &Node, f: &dyn Fn(Point) -> Point, piece: f64, outline: Outliner) -> Node {
|
||||
/// Distort Pattern Fills: the tiles pattern swatch `name` painted with placement `xf` lays over
|
||||
/// `region` (see [`crate::PatternDef::instances_in`]); `None` without such a swatch.
|
||||
pub type PatternTiles<'a> = &'a dyn Fn(&str, Affine, Rect) -> Option<Vec<Node>>;
|
||||
|
||||
/// What live evaluation asks of the crates above this one (`vectorcraft-effects` supplies them).
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct Hooks<'a> {
|
||||
/// Type as its glyph outlines, a symbol instance as its art (see [`Outliner`]).
|
||||
pub outline: Outliner<'a>,
|
||||
/// Distort Appearance: a path's strokes and geometry effects baked into filled art (`None`
|
||||
/// when it has none to bake).
|
||||
pub appearance: Outliner<'a>,
|
||||
/// Distort Pattern Fills: the tiles a pattern fill lays (see [`PatternTiles`]).
|
||||
pub pattern: Option<PatternTiles<'a>>,
|
||||
}
|
||||
|
||||
impl<'a> From<Outliner<'a>> for Hooks<'a> {
|
||||
fn from(outline: Outliner<'a>) -> Self {
|
||||
Self { outline, ..Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The affine map that best matches `f` near `p` (finite differences with step `eps`).
|
||||
pub fn affine_near(f: &dyn Fn(Point) -> Point, p: Point, eps: f64) -> Affine {
|
||||
let o = f(p);
|
||||
let ex = (f(p + Vec2::new(eps, 0.0)) - f(p - Vec2::new(eps, 0.0))) / (2.0 * eps);
|
||||
let ey = (f(p + Vec2::new(0.0, eps)) - f(p - Vec2::new(0.0, eps))) / (2.0 * eps);
|
||||
let lin = Affine::new([ex.x, ex.y, ey.x, ey.y, 0.0, 0.0]);
|
||||
Affine::translate(o.to_vec2()) * lin * Affine::translate(-p.to_vec2())
|
||||
}
|
||||
|
||||
/// How deep hooks may nest art inside art (symbols in symbols, envelopes in envelopes) before
|
||||
/// the rest is left as it is: a file can make a symbol contain itself.
|
||||
const MAX_HOOK_DEPTH: u32 = 16;
|
||||
|
||||
/// How an envelope pushes art through its point map.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Warper<'a> {
|
||||
f: &'a dyn Fn(Point) -> Point,
|
||||
/// Longest piece a segment is cut into before mapping.
|
||||
piece: f64,
|
||||
/// Finite-difference step for the local affine maps gradients follow.
|
||||
eps: f64,
|
||||
/// Rows and columns of the pieces an image is cut into.
|
||||
cells: usize,
|
||||
hooks: Hooks<'a>,
|
||||
options: EnvelopeOptions,
|
||||
/// How many hooks the art being mapped came through.
|
||||
depth: u32,
|
||||
}
|
||||
|
||||
impl Warper<'_> {
|
||||
/// This warper one hook deeper (`None` past [`MAX_HOOK_DEPTH`]).
|
||||
fn deeper(&self) -> Option<Self> {
|
||||
(self.depth < MAX_HOOK_DEPTH).then_some(Self { depth: self.depth + 1, ..*self })
|
||||
}
|
||||
}
|
||||
|
||||
/// `to` (art standing in for `from`: its outlines, its art) with `from`'s name, transparency,
|
||||
/// opacity mask and effects.
|
||||
fn carry_look(from: &Node, mut to: Node) -> Node {
|
||||
to.name = from.name.clone();
|
||||
to.opacity = from.opacity;
|
||||
to.blend = from.blend;
|
||||
to.isolate = from.isolate;
|
||||
to.knockout = from.knockout;
|
||||
to.knockout_shape = from.knockout_shape;
|
||||
to.mask = from.mask.clone();
|
||||
to.appearance.effects = from.appearance.effects.clone();
|
||||
to
|
||||
}
|
||||
|
||||
fn map_node(n: &Node, w: &Warper) -> Node {
|
||||
let deeper = w.deeper();
|
||||
// Distort Appearance: strokes and geometry effects bend with the art.
|
||||
if w.options.distort_appearance
|
||||
&& let Some(d) = &deeper
|
||||
&& let Some(o) = w.hooks.appearance.and_then(|h| h(n))
|
||||
{
|
||||
return map_node(&o, d);
|
||||
}
|
||||
// Distort Pattern Fills: the tiles bend with the art (not the patterns inside them).
|
||||
if w.options.patterns()
|
||||
&& let Some(d) = &deeper
|
||||
&& let Some(o) = w.hooks.pattern.and_then(|p| pattern_art(n, p))
|
||||
{
|
||||
let inner = Warper { options: EnvelopeOptions { distort_pattern_fills: false, ..d.options }, ..*d };
|
||||
return map_node(&o, &inner);
|
||||
}
|
||||
let mut out = n.clone();
|
||||
// Distort Linear Gradients: the gradient vectors follow the warp where they sit.
|
||||
if w.options.gradients()
|
||||
&& let Some(b) = n.geometric_bounds()
|
||||
{
|
||||
out.appearance.warp_linear_gradients(b, &|p| affine_near(w.f, p, w.eps));
|
||||
}
|
||||
if let Some(m) = &mut out.mask {
|
||||
m.art = Arc::new(map_node(&m.art, w));
|
||||
}
|
||||
match &mut out.kind {
|
||||
NodeKind::Path { path, live, .. } => {
|
||||
*path = map_nonlinear(path, piece, f);
|
||||
*path = map_nonlinear(path, w.piece, w.f);
|
||||
*live = None;
|
||||
}
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => {
|
||||
for c in children.iter_mut() {
|
||||
*c = Arc::new(map_node(c, f, piece, outline));
|
||||
*c = Arc::new(map_node(c, w));
|
||||
}
|
||||
}
|
||||
NodeKind::Mesh(m) => m.map(f),
|
||||
NodeKind::Mesh(m) => m.map(w.f),
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) => {
|
||||
let g = expanded_group(n, outline);
|
||||
return map_node(&g, f, piece, outline);
|
||||
}
|
||||
NodeKind::Text(_) => {
|
||||
if let Some(o) = outline.and_then(|h| h(n)) {
|
||||
return map_node(&o, f, piece, outline);
|
||||
if let Some(d) = &deeper {
|
||||
return map_node(&expanded_group_hooks(n, w.hooks), d);
|
||||
}
|
||||
}
|
||||
NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => {}
|
||||
NodeKind::Text(_) | NodeKind::SymbolInstance { .. } => {
|
||||
if let Some(d) = &deeper
|
||||
&& let Some(o) = w.hooks.outline.and_then(|h| h(n))
|
||||
{
|
||||
return map_node(&carry_look(n, o), d);
|
||||
}
|
||||
}
|
||||
NodeKind::Image(im) => {
|
||||
let im = im.clone();
|
||||
return warp_image(&out, &im, w);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Evaluate an envelope: its content mapped through the envelope.
|
||||
/// The affine map taking triangle `s` to triangle `d` (`None` when `s` is degenerate).
|
||||
fn affine_between(s: [Point; 3], d: [Point; 3]) -> Option<Affine> {
|
||||
let basis = |p: [Point; 3]| Affine::new([p[1].x - p[0].x, p[1].y - p[0].y, p[2].x - p[0].x, p[2].y - p[0].y, p[0].x, p[0].y]);
|
||||
let bs = basis(s);
|
||||
if bs.determinant().abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let a = basis(d) * bs.inverse();
|
||||
a.is_finite().then_some(a)
|
||||
}
|
||||
|
||||
/// Image `n` (`im`) pushed through the warp as a raster mesh warp: its frame cut into
|
||||
/// `w.cells`² cells of two triangles, each a clip group showing the image under the affine map
|
||||
/// that takes the triangle where the warp takes its corners. Each piece reaches a little past its
|
||||
/// edges (at most half a point) so neighbours overlap and no seams show.
|
||||
fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node {
|
||||
let k = w.cells.max(1);
|
||||
let (pw, ph) = (im.width as f64, im.height as f64);
|
||||
let at = |i: usize, j: usize| Point::new(pw * i as f64 / k as f64, ph * j as f64 / k as f64);
|
||||
let mut pieces = Vec::with_capacity(2 * k * k);
|
||||
for j in 0..k {
|
||||
for i in 0..k {
|
||||
let s = [at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1)];
|
||||
let d = s.map(|p| (w.f)(im.xf * p));
|
||||
for t in [[0, 1, 2], [0, 2, 3]] {
|
||||
let (st, dt) = (t.map(|v| s[v]), t.map(|v| d[v]));
|
||||
let Some(xf) = affine_between(st, dt) else { continue };
|
||||
let c = Point::new((dt[0].x + dt[1].x + dt[2].x) / 3.0, (dt[0].y + dt[1].y + dt[2].y) / 3.0);
|
||||
let grow = (dt[0].distance(dt[1]).max(dt[1].distance(dt[2])) * 0.02).min(0.5);
|
||||
let tri: Vec<Point> = dt
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let v = *p - c;
|
||||
let len = v.hypot();
|
||||
if len > 1e-12 { *p + v * (grow / len) } else { *p }
|
||||
})
|
||||
.collect();
|
||||
let mut clip = Node::path(n.id, PathData::single(SubPath::polyline(&tri, true)), Appearance::default());
|
||||
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
let image = Node::new(n.id, NodeKind::Image(ImageObject { xf, ..im.clone() }));
|
||||
pieces.push(Arc::new(Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true })));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut g = n.clone();
|
||||
g.kind = NodeKind::Group { children: pieces, clip: false };
|
||||
g
|
||||
}
|
||||
|
||||
/// Distort Pattern Fills: path or compound path `n` with each visible pattern fill turned into the
|
||||
/// tiles it paints in a clip group shaped like `n` (with the fill's opacity and blend mode), its
|
||||
/// other fills and strokes on copies of it, in paint order, under `n`'s transparency. `None`
|
||||
/// without a pattern fill whose definition `pattern` finds.
|
||||
fn pattern_art(n: &Node, tiles: PatternTiles) -> Option<Node> {
|
||||
let (path, rule, _) = crate::blend::node_path(n)?;
|
||||
let pattern_fill = |i: &AppearanceItem| matches!(i, AppearanceItem::Fill(f) if f.visible && matches!(f.paint, Paint::Pattern { .. }));
|
||||
if !n.appearance.items.iter().any(pattern_fill) {
|
||||
return None;
|
||||
}
|
||||
let bounds = path.bounds()?;
|
||||
let def = |i: &AppearanceItem| match i {
|
||||
AppearanceItem::Fill(f) if f.visible => match &f.paint {
|
||||
Paint::Pattern { pattern: name, xf } => tiles(name, *xf, bounds).map(|t| (t, f.opacity, f.blend)),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
let plain = |items: Vec<AppearanceItem>| {
|
||||
Arc::new(Node {
|
||||
appearance: Appearance { items, ..Default::default() },
|
||||
opacity: 1.0,
|
||||
blend: Default::default(),
|
||||
isolate: false,
|
||||
knockout: Default::default(),
|
||||
knockout_shape: false,
|
||||
mask: None,
|
||||
name: None,
|
||||
..n.clone()
|
||||
})
|
||||
};
|
||||
let mut pieces = vec![];
|
||||
let mut rest = vec![];
|
||||
for i in &n.appearance.items {
|
||||
let Some((tiles, opacity, blend)) = def(i) else {
|
||||
rest.push(i.clone());
|
||||
continue;
|
||||
};
|
||||
if !rest.is_empty() {
|
||||
pieces.push(plain(std::mem::take(&mut rest)));
|
||||
}
|
||||
let mut clip = Node::path(n.id, path.clone(), Appearance::default());
|
||||
if let NodeKind::Path { clipping, rule: r, .. } = &mut clip.kind {
|
||||
(*clipping, *r) = (true, rule);
|
||||
}
|
||||
let children = std::iter::once(clip).chain(tiles).map(Arc::new).collect();
|
||||
let mut g = Node::new(n.id, NodeKind::Group { children, clip: true });
|
||||
g.opacity = opacity;
|
||||
g.blend = blend;
|
||||
pieces.push(Arc::new(g));
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
pieces.push(plain(rest));
|
||||
}
|
||||
Some(Node {
|
||||
kind: NodeKind::Group { children: pieces, clip: false },
|
||||
appearance: Appearance { effects: n.appearance.effects.clone(), ..Default::default() },
|
||||
..n.clone()
|
||||
})
|
||||
}
|
||||
|
||||
/// Evaluate an envelope of `kind` around `content` on the page axes with the options envelopes
|
||||
/// had before Envelope Options (see [`expand_live_hooks`] for an envelope node).
|
||||
pub fn envelope_expand(content: &[Arc<Node>], kind: &EnvelopeKind, fidelity: f64, outline: Outliner) -> Vec<Node> {
|
||||
let Some(src) = nodes_bounds(content) else { return content.iter().map(|c| (**c).clone()).collect() };
|
||||
let f = envelope_mapper(kind, src);
|
||||
let diag = src.width().hypot(src.height()).max(1e-6);
|
||||
let piece = diag / (8.0 + fidelity.clamp(0.0, 100.0) / 100.0 * 56.0);
|
||||
content.iter().map(|c| map_node(c, &*f, piece, outline)).collect()
|
||||
expand_envelope(content, kind, fidelity, EnvelopeOptions::default(), Affine::IDENTITY, outline.into())
|
||||
}
|
||||
|
||||
/// The surface an envelope maps `content` onto, sampled on a `rows`×`cols` grid as a mesh whose
|
||||
/// handles follow the surface and whose points are `color`: Release gives it back as the
|
||||
/// envelope's shape and Reset with Mesh starts from it. `None` when the content has no bounds.
|
||||
pub fn envelope_surface(content: &[Arc<Node>], kind: &EnvelopeKind, rows: u32, cols: u32, color: Color) -> Option<GradientMesh> {
|
||||
let src = nodes_bounds(content)?;
|
||||
let f = envelope_mapper(kind, src);
|
||||
let at = |u: f64, v: f64| f(Point::new(src.x0 + u * src.width(), src.y0 + v * src.height()));
|
||||
Some(GradientMesh::from_surface(rows, cols, &at, &|_, _| color))
|
||||
/// An envelope's content pushed through its map: Fidelity sets how finely curves are cut (and
|
||||
/// into how many pieces images are), the options what bends with the art.
|
||||
fn expand_envelope(content: &[Arc<Node>], kind: &EnvelopeKind, fidelity: f64, options: EnvelopeOptions, frame: Affine, hooks: Hooks) -> Vec<Node> {
|
||||
let Some(map) = EnvelopeMap::new(content, kind, frame) else { return content.iter().map(|c| (**c).clone()).collect() };
|
||||
let src = map.src();
|
||||
let fid = if fidelity.is_finite() { fidelity.clamp(0.0, 100.0) / 100.0 } else { 0.5 };
|
||||
let f = |p: Point| map.map(p);
|
||||
let w = Warper {
|
||||
f: &f,
|
||||
piece: src.width().hypot(src.height()).max(1e-6) / (8.0 + fid * 56.0),
|
||||
eps: (src.width().max(src.height()) * 0.05).max(0.5),
|
||||
cells: (4.0 + fid * 12.0).round() as usize,
|
||||
hooks,
|
||||
options,
|
||||
depth: 0,
|
||||
};
|
||||
content.iter().map(|c| map_node(c, &w)).collect()
|
||||
}
|
||||
|
||||
/// Envelope bounds (the image of the content box, sampled).
|
||||
pub fn envelope_bounds(content: &[Arc<Node>], kind: &EnvelopeKind) -> Option<Rect> {
|
||||
/// Envelope bounds: a mesh's points, a top object's path, the warp's surface (sampled).
|
||||
pub fn envelope_bounds(content: &[Arc<Node>], kind: &EnvelopeKind, frame: Affine) -> Option<Rect> {
|
||||
match kind {
|
||||
EnvelopeKind::Mesh { points, .. } => {
|
||||
let first = *points.first()?;
|
||||
Some(points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p)))
|
||||
}
|
||||
EnvelopeKind::TopObject { path } => path.bounds(),
|
||||
EnvelopeKind::Warp { .. } => {
|
||||
let src = nodes_bounds(content)?;
|
||||
let f = envelope_mapper(kind, src);
|
||||
const N: usize = 16;
|
||||
let mut r: Option<Rect> = None;
|
||||
for j in 0..=N {
|
||||
for i in 0..=N {
|
||||
let q = f(Point::new(src.x0 + src.width() * i as f64 / N as f64, src.y0 + src.height() * j as f64 / N as f64));
|
||||
r = Some(r.map_or(Rect::from_points(q, q), |r| r.union_pt(q)));
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
EnvelopeKind::Warp { .. } => EnvelopeMap::new(content, kind, frame)?.bounds(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1195,9 +1500,14 @@ pub fn expand_live(n: &Node) -> Vec<Node> {
|
|||
}
|
||||
|
||||
pub fn expand_live_with(n: &Node, outline: Outliner) -> Vec<Node> {
|
||||
expand_live_hooks(n, outline.into())
|
||||
}
|
||||
|
||||
/// [`expand_live_with`] with everything an envelope can ask of the crates above (see [`Hooks`]).
|
||||
pub fn expand_live_hooks(n: &Node, hooks: Hooks) -> Vec<Node> {
|
||||
match &n.kind {
|
||||
NodeKind::Blend { children, spec } => blend_expand(children, spec),
|
||||
NodeKind::Envelope { content, kind, fidelity, .. } => envelope_expand(content, kind, *fidelity, outline),
|
||||
NodeKind::Envelope { content, kind, fidelity, options, frame, .. } => expand_envelope(content, kind, *fidelity, *options, *frame, hooks),
|
||||
NodeKind::Mesh(m) => mesh_quad_nodes(m, 8),
|
||||
NodeKind::Repeat(r) => r.expand(),
|
||||
_ => vec![n.clone()],
|
||||
|
|
@ -1206,7 +1516,12 @@ pub fn expand_live_with(n: &Node, outline: Outliner) -> Vec<Node> {
|
|||
|
||||
/// A live object evaluated into a plain group (keeping the object's id, name and transparency).
|
||||
pub fn expanded_group(n: &Node, outline: Outliner) -> Node {
|
||||
let children = expand_live_with(n, outline).into_iter().map(Arc::new).collect();
|
||||
expanded_group_hooks(n, outline.into())
|
||||
}
|
||||
|
||||
/// [`expanded_group`] with [`Hooks`].
|
||||
pub fn expanded_group_hooks(n: &Node, hooks: Hooks) -> Node {
|
||||
let children = expand_live_hooks(n, hooks).into_iter().map(Arc::new).collect();
|
||||
let mut g = Node::new(n.id, NodeKind::Group { children, clip: false });
|
||||
g.name = n.name.clone();
|
||||
g.visible = n.visible;
|
||||
|
|
@ -1226,11 +1541,16 @@ pub fn expanded_group(n: &Node, outline: Outliner) -> Node {
|
|||
|
||||
/// Recursively replace every live object in `n` by plain geometry (for exporters).
|
||||
pub fn expand_deep(n: &Node, outline: Outliner) -> Node {
|
||||
let mut out = if is_live(n) { expanded_group(n, outline) } else { n.clone() };
|
||||
expand_deep_hooks(n, outline.into())
|
||||
}
|
||||
|
||||
/// [`expand_deep`] with [`Hooks`].
|
||||
pub fn expand_deep_hooks(n: &Node, hooks: Hooks) -> Node {
|
||||
let mut out = if is_live(n) { expanded_group_hooks(n, hooks) } else { n.clone() };
|
||||
if let Some(ch) = out.children_mut() {
|
||||
for c in ch.iter_mut() {
|
||||
if subtree_has_live(c) {
|
||||
*c = Arc::new(expand_deep(c, outline));
|
||||
*c = Arc::new(expand_deep_hooks(c, hooks));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1288,6 +1608,16 @@ mod tests {
|
|||
assert!((p.x - 25.0).abs() < 1e-6 && (p.y - 25.0).abs() < 1e-6, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_go_on_past_the_content_box() {
|
||||
// A stroke outline reaches past the box: a flat mesh and a rectangle stay the identity there.
|
||||
let r = Rect::new(0.0, 0.0, 100.0, 100.0);
|
||||
let p = grid_eval(2, 2, &grid_points(r, 2, 2), -0.05, 1.1);
|
||||
assert!((p.x + 5.0).abs() < 1e-6 && (p.y - 110.0).abs() < 1e-6, "{p:?}");
|
||||
let c = Coons::from_path(&shapes::rectangle(r)).unwrap().eval(1.05, -0.1);
|
||||
assert!((c.x - 105.0).abs() < 1e-6 && (c.y + 10.0).abs() < 1e-6, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_identity() {
|
||||
let r = Rect::new(0.0, 0.0, 100.0, 100.0);
|
||||
|
|
|
|||
|
|
@ -275,6 +275,11 @@ pub enum NodeKind {
|
|||
/// Envelope Options besides Fidelity.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
options: crate::live::EnvelopeOptions,
|
||||
/// The envelope's own axes (→ the document): the transforms it took since it was made that
|
||||
/// the page axes can't stand for (rotation, shear, reflection). The content maps from its
|
||||
/// bounds in this frame, and a warp bends along it.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
frame: Affine,
|
||||
},
|
||||
/// Gradient mesh.
|
||||
Mesh(GradientMesh),
|
||||
|
|
@ -536,7 +541,7 @@ impl Node {
|
|||
let b = crate::live::nodes_bounds(children);
|
||||
vectorcraft_geom::union_opt(b, spec.spine.as_ref().and_then(|s| s.bounds()))
|
||||
}
|
||||
NodeKind::Envelope { content, kind, .. } => crate::live::envelope_bounds(content, kind),
|
||||
NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, *frame),
|
||||
NodeKind::Mesh(m) => m.bounds(),
|
||||
NodeKind::Repeat(r) => r.bounds(),
|
||||
}
|
||||
|
|
@ -639,10 +644,11 @@ impl Node {
|
|||
s.transform(a);
|
||||
}
|
||||
}
|
||||
NodeKind::Envelope { content, kind, .. } => {
|
||||
NodeKind::Envelope { content, kind, frame, .. } => {
|
||||
for c in content.iter_mut() {
|
||||
Arc::make_mut(c).transform_scaled(a, sc);
|
||||
}
|
||||
*frame = crate::live::envelope_frame(a * *frame);
|
||||
match kind {
|
||||
EnvelopeKind::Mesh { points, .. } => {
|
||||
for p in points.iter_mut() {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{FillRule, PathData};
|
||||
|
||||
use crate::group::{has_own_paint, paints};
|
||||
|
|
@ -281,6 +281,48 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Envelope Options → Distort Appearance: path or compound path `n` as plain filled art that an
|
||||
/// envelope bends as a whole: its geometry effects applied, every visible fill a copy of the path
|
||||
/// painted by that fill and every visible stroke without a brush its filled outline in the
|
||||
/// stroke's paint, opacity and blend mode (a brushed stroke stays a stroke), in paint order, as
|
||||
/// the members of a group keeping `n`'s id, transparency, opacity mask and raster effects. `None`
|
||||
/// when it has neither a stroke to outline nor a geometry effect, so bending first changes nothing.
|
||||
pub fn bake_appearance(n: &Node) -> Option<Node> {
|
||||
if !matches!(n.kind, NodeKind::Path { clipping: false, guide: false, .. } | NodeKind::Compound { .. }) {
|
||||
return None;
|
||||
}
|
||||
let outlined = |i: &AppearanceItem| paints(i) && matches!(i, AppearanceItem::Stroke(s) if s.brush.is_none());
|
||||
let geometric = has_geometry(&n.appearance.effects) || n.appearance.items.iter().any(|i| has_geometry(item_effects(i)));
|
||||
if !geometric && !n.appearance.items.iter().any(outlined) {
|
||||
return None;
|
||||
}
|
||||
let (g, rule, ctx, mut m) = leaf_base(n)?;
|
||||
let piece = |path: PathData, rule: FillRule, item: AppearanceItem| {
|
||||
let mut p = Node::path(n.id, path, Appearance { items: vec![item], ..Default::default() });
|
||||
if let NodeKind::Path { rule: r, .. } = &mut p.kind {
|
||||
*r = rule;
|
||||
}
|
||||
Arc::new(p)
|
||||
};
|
||||
let mut pieces = vec![];
|
||||
for item in n.appearance.items.iter().filter(|i| paints(i)) {
|
||||
let ig = apply(item_effects(item), &g, &ctx.item(item));
|
||||
let mut it = item.clone();
|
||||
clear_item_effects(&mut it);
|
||||
pieces.push(match &it {
|
||||
AppearanceItem::Stroke(st) if st.brush.is_none() => {
|
||||
let fill = FillLayer { opacity: st.opacity, blend: st.blend, ..FillLayer::new(st.paint.clone()) };
|
||||
piece(crate::stroke::outline_region(&ig, rule, st), FillRule::NonZero, AppearanceItem::Fill(fill))
|
||||
}
|
||||
_ => piece(ig, rule, it),
|
||||
});
|
||||
}
|
||||
m.appearance.items.clear();
|
||||
m.appearance.contents_index = None;
|
||||
m.kind = NodeKind::Group { children: pieces, clip: false };
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// A copy of `doc` with every live geometry effect baked into plain paths, or `None` when the
|
||||
/// document has none (export it as is).
|
||||
pub fn bake_document(doc: &Document) -> Option<Document> {
|
||||
|
|
|
|||
|
|
@ -53,13 +53,13 @@ use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
|
|||
use vectorcraft_geom::{BezPath, FillRule, PathData, Rect};
|
||||
|
||||
pub use adjust::{ADJUSTMENTS, ColorMap, ImageHook, adjust, adjust_in_document, color_map, curve_at, curve_points, has_adjustment, is_adjustment};
|
||||
pub use bake::{StrokeArt, bake_document, expand_art, expand_leaf, fresh_ids, needs_bake};
|
||||
pub use bake::{StrokeArt, bake_appearance, bake_document, expand_art, expand_leaf, fresh_ids, needs_bake};
|
||||
pub use clip::clip_outline;
|
||||
pub use group::{
|
||||
OutlineHook, PATHFINDER_EFFECTS, evaluate_container, has_container_appearance, has_pathfinder, is_pathfinder, member_shapes, paints,
|
||||
pathfinder_children,
|
||||
};
|
||||
pub use live::{expand_live, expand_live_deep, text_outliner};
|
||||
pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner};
|
||||
pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
|
||||
pub use raster::{RasterFx, outset, raster_effects};
|
||||
pub use reshape::{expand_outlined, needs_outline, outline_art, outline_text, reshape};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
//! Live objects (blends, envelopes, gradient meshes, repeats) evaluated alike for the renderer and
|
||||
//! every exporter, with what the pure evaluation in [`vectorcraft_doc::live`] can't do itself: type
|
||||
//! inside an envelope distorts as its glyph outlines, run by run ([`outline_text`]).
|
||||
//! every exporter, with what the pure evaluation in [`vectorcraft_doc::live`] can't do itself
|
||||
//! ([`Hooks`]): type inside an envelope distorts as its glyph outlines, run by run
|
||||
//! ([`outline_text`]); symbol instances as their symbol's art; with Distort Appearance strokes and
|
||||
//! geometry effects bend with the art ([`bake_appearance`]); with Distort Pattern Fills the pattern
|
||||
//! tiles do.
|
||||
|
||||
use vectorcraft_doc::Node;
|
||||
use vectorcraft_doc::live::{self, Outliner};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::outline_text;
|
||||
use vectorcraft_doc::live::{self, Hooks, Outliner};
|
||||
use vectorcraft_doc::{Document, Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, Rect};
|
||||
|
||||
use crate::{bake_appearance, outline_text};
|
||||
|
||||
/// The text outliner live evaluation uses: type as its glyph outlines, one path per run in the
|
||||
/// run's paint ([`outline_text`]).
|
||||
|
|
@ -13,13 +19,39 @@ pub fn text_outliner() -> Outliner<'static> {
|
|||
Some(&outline_text)
|
||||
}
|
||||
|
||||
/// One level of evaluation of live object `n` ([`live::expand_live`]) with type outlined.
|
||||
pub fn expand_live(n: &Node) -> Vec<Node> {
|
||||
live::expand_live_with(n, text_outliner())
|
||||
/// Type outlined, or a symbol instance as its symbol's art (from `doc`) placed by the instance.
|
||||
fn outline(doc: Option<&Document>, n: &Node) -> Option<Node> {
|
||||
match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
let sym = doc?.symbols.iter().find(|s| s.name == *symbol)?;
|
||||
let mut art = (*sym.art).clone();
|
||||
art.transform(*xf, false);
|
||||
Some(Node::group(n.id, vec![Arc::new(art)]))
|
||||
}
|
||||
_ => outline_text(n),
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` with every live object in it replaced by its evaluated art ([`live::expand_deep`]) and type
|
||||
/// inside envelopes outlined: what the exporters write for live objects.
|
||||
pub fn expand_live_deep(n: &Node) -> Node {
|
||||
live::expand_deep(n, text_outliner())
|
||||
/// Run `f` with the hooks live evaluation needs; symbols and patterns come from `doc` (without a
|
||||
/// document, symbol instances and pattern tiles stay as they are).
|
||||
fn with_hooks<R>(doc: Option<&Document>, f: impl FnOnce(Hooks) -> R) -> R {
|
||||
let outline = |n: &Node| outline(doc, n);
|
||||
let tiles = |name: &str, xf: Affine, region: Rect| Some(doc?.pattern(name)?.instances_in(xf, region));
|
||||
f(Hooks { outline: Some(&outline), appearance: Some(&bake_appearance), pattern: Some(&tiles) })
|
||||
}
|
||||
|
||||
/// One level of evaluation of live object `n` ([`live::expand_live_hooks`]).
|
||||
pub fn expand_live(doc: Option<&Document>, n: &Node) -> Vec<Node> {
|
||||
with_hooks(doc, |h| live::expand_live_hooks(n, h))
|
||||
}
|
||||
|
||||
/// Live object `n` evaluated into a plain group ([`live::expanded_group_hooks`]): Expand.
|
||||
pub fn expanded_live_group(doc: Option<&Document>, n: &Node) -> Node {
|
||||
with_hooks(doc, |h| live::expanded_group_hooks(n, h))
|
||||
}
|
||||
|
||||
/// `n` with every live object in it replaced by its evaluated art ([`live::expand_deep_hooks`]):
|
||||
/// what the exporters write for live objects.
|
||||
pub fn expand_live_deep(doc: Option<&Document>, n: &Node) -> Node {
|
||||
with_hooks(doc, |h| live::expand_deep_hooks(n, h))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ use std::sync::Arc;
|
|||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::live::{
|
||||
self, BlendDefaults, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, EnvelopeOptions, GradientMesh, MeshAppearance, PreserveShape, Spine,
|
||||
self, BlendDefaults, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, EnvelopeMap, EnvelopeOptions, GradientMesh, MeshAppearance,
|
||||
PreserveShape, Spine,
|
||||
};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_geom::{Affine, PathData, Point};
|
||||
|
|
@ -930,7 +931,7 @@ fn envelope_defaults(s: &Session) -> (EnvelopeOptions, f64) {
|
|||
}
|
||||
|
||||
fn envelope(id: NodeId, content: Vec<Arc<Node>>, kind: EnvelopeKind, (options, fidelity): (EnvelopeOptions, f64)) -> Node {
|
||||
Node::new(id, NodeKind::Envelope { content, kind, fidelity, editing: false, options })
|
||||
Node::new(id, NodeKind::Envelope { content, kind, fidelity, editing: false, options, frame: Affine::IDENTITY })
|
||||
}
|
||||
|
||||
/// `rows` and `cols` (or `columns`), 1..50 each, defaulting to `default`.
|
||||
|
|
@ -954,14 +955,11 @@ fn env_make_warp(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
/// A mesh envelope of `rows`×`cols` patches over `content`: following the surface of `shape` (an
|
||||
/// envelope's current kind), or a flat grid over the content's bounds.
|
||||
fn mesh_kind(content: &[Arc<Node>], shape: Option<&EnvelopeKind>, rows: u32, cols: u32) -> Option<EnvelopeKind> {
|
||||
let points = match shape {
|
||||
Some(k) => live::envelope_surface(content, k, rows, cols, Color::BLACK)?.points.into_iter().map(|m| m.p).collect(),
|
||||
None => live::grid_points(live::nodes_bounds(content)?, rows, cols),
|
||||
};
|
||||
Some(EnvelopeKind::Mesh { rows, cols, points })
|
||||
/// A mesh envelope of `rows`×`cols` patches for an envelope with map `map`: following its
|
||||
/// current surface (`maintain`), or a flat grid over its content.
|
||||
fn mesh_kind(map: &EnvelopeMap, maintain: bool, rows: u32, cols: u32) -> EnvelopeKind {
|
||||
let points = if maintain { map.surface_mesh(rows, cols, Color::BLACK).points.into_iter().map(|m| m.p).collect() } else { map.grid(rows, cols) };
|
||||
EnvelopeKind::Mesh { rows, cols, points }
|
||||
}
|
||||
|
||||
fn env_make_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
|
@ -970,7 +968,8 @@ fn env_make_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let roots = roots_param(s, p)?;
|
||||
let st = s.doc()?;
|
||||
let nodes: Vec<Arc<Node>> = roots.iter().filter_map(|id| st.doc.node(*id).cloned()).map(Arc::new).collect();
|
||||
let kind = mesh_kind(&nodes, None, rows, cols).ok_or_else(|| bad(C, "selection has no bounds"))?;
|
||||
let src = live::nodes_bounds(&nodes).ok_or_else(|| bad(C, "selection has no bounds"))?;
|
||||
let kind = EnvelopeKind::Mesh { rows, cols, points: live::grid_points(src, rows, cols) };
|
||||
let defaults = envelope_defaults(s);
|
||||
let id = s.edit("Make Envelope", |d, sel| wrap(d, sel, &roots, |id, content| envelope(id, content, kind, defaults)))?;
|
||||
Ok(json!({ "id": id.0 }))
|
||||
|
|
@ -1005,13 +1004,15 @@ fn env_make_top(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
/// The kind `f` makes of each selected envelope (from its content and its current kind).
|
||||
fn reshaped(s: &Session, f: impl Fn(&[Arc<Node>], &EnvelopeKind) -> Result<EnvelopeKind>) -> Result<Vec<(NodeId, EnvelopeKind)>> {
|
||||
/// The kind `f` makes of each selected envelope (from the envelope and its current kind).
|
||||
fn reshaped(s: &Session, f: impl Fn(&Node, &EnvelopeKind) -> Result<EnvelopeKind>) -> Result<Vec<(NodeId, EnvelopeKind)>> {
|
||||
let doc = &s.doc()?.doc;
|
||||
let mut out = vec![];
|
||||
for e in selected_of(s, is_envelope) {
|
||||
if let Some(NodeKind::Envelope { content, kind, .. }) = doc.node(e).map(|n| &n.kind) {
|
||||
out.push((e, f(content, kind)?));
|
||||
if let Some(n) = doc.node(e)
|
||||
&& let NodeKind::Envelope { kind, .. } = &n.kind
|
||||
{
|
||||
out.push((e, f(n, kind)?));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
|
|
@ -1039,13 +1040,14 @@ fn env_reset_warp(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
fn env_reset_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.envelope.resetWithMesh";
|
||||
let maintain = bool_or(p, "maintainShape", true);
|
||||
let kinds = reshaped(s, |content, kind| {
|
||||
let kinds = reshaped(s, |env, kind| {
|
||||
let own = match kind {
|
||||
EnvelopeKind::Mesh { rows, cols, .. } => (*rows, *cols),
|
||||
_ => (4, 4),
|
||||
};
|
||||
let (rows, cols) = rows_cols(p, C, own)?;
|
||||
mesh_kind(content, maintain.then_some(kind), rows, cols).ok_or_else(|| bad(C, "the envelope's content has no bounds"))
|
||||
let map = EnvelopeMap::of(env).ok_or_else(|| bad(C, "the envelope's content has no bounds"))?;
|
||||
Ok(mesh_kind(&map, maintain, rows, cols))
|
||||
})?;
|
||||
set_kinds(s, "Reset with Mesh", kinds)
|
||||
}
|
||||
|
|
@ -1096,9 +1098,9 @@ fn env_info(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
Ok(v)
|
||||
}
|
||||
|
||||
/// What Release gives back as an envelope's shape: a top object's path, or the surface of a warp
|
||||
/// or mesh envelope as a gradient mesh, painted grey (id 0).
|
||||
fn envelope_shape(content: &[Arc<Node>], kind: &EnvelopeKind) -> Option<Node> {
|
||||
/// What Release gives back as the shape of envelope `env` (of `kind`): a top object's path, or the
|
||||
/// surface of a warp or mesh envelope as a gradient mesh, painted grey (id 0).
|
||||
fn envelope_shape(env: &Node, kind: &EnvelopeKind) -> Option<Node> {
|
||||
let grey = Color::gray(0.25);
|
||||
let (rows, cols) = match kind {
|
||||
EnvelopeKind::TopObject { path } => {
|
||||
|
|
@ -1107,7 +1109,7 @@ fn envelope_shape(content: &[Arc<Node>], kind: &EnvelopeKind) -> Option<Node> {
|
|||
EnvelopeKind::Mesh { rows, cols, .. } => (*rows, *cols),
|
||||
EnvelopeKind::Warp { .. } => (4, 4),
|
||||
};
|
||||
live::envelope_surface(content, kind, rows, cols, grey).map(|m| Node::new(NodeId(0), NodeKind::Mesh(m)))
|
||||
EnvelopeMap::of(env).map(|map| Node::new(NodeId(0), NodeKind::Mesh(map.surface_mesh(rows, cols, grey))))
|
||||
}
|
||||
|
||||
/// Released content keeps envelope `env`'s opacity, blend mode, isolation, knockout and opacity
|
||||
|
|
@ -1143,7 +1145,7 @@ fn env_release(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
let Some(n) = d.node(*e).cloned() else { continue };
|
||||
let NodeKind::Envelope { content, kind, .. } = &n.kind else { continue };
|
||||
let mut nodes = carry_transparency(d, &n, content.iter().map(|c| (**c).clone()).collect());
|
||||
if let Some(mut shape) = envelope_shape(content, kind) {
|
||||
if let Some(mut shape) = envelope_shape(&n, kind) {
|
||||
shape.id = d.alloc_id();
|
||||
nodes.push(shape);
|
||||
}
|
||||
|
|
@ -1195,7 +1197,7 @@ fn env_options(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
/// transparency and opacity mask; the generated pieces get ids of their own.
|
||||
fn expand_envelope(d: &mut Document, id: NodeId) -> Result<()> {
|
||||
let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?;
|
||||
let mut g = live::expanded_group(&n, vectorcraft_render::effects::text_outliner());
|
||||
let mut g = vectorcraft_render::effects::expanded_live_group(Some(d), &n);
|
||||
fix_ids(d, &mut g);
|
||||
// Outlined type repeats its object's id on its pieces.
|
||||
vectorcraft_render::effects::fresh_ids(d, &mut g, &mut Default::default());
|
||||
|
|
|
|||
|
|
@ -1210,6 +1210,8 @@ mod tests_emptytype;
|
|||
#[cfg(test)]
|
||||
mod tests_envelope;
|
||||
#[cfg(test)]
|
||||
mod tests_envelope_distort;
|
||||
#[cfg(test)]
|
||||
mod tests_expand;
|
||||
#[cfg(test)]
|
||||
mod tests_eyedropper;
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ fn reset_with_warp_and_mesh_switch_the_kind_and_keep_the_content() {
|
|||
assert!(s.execute("object.envelope.resetWithWarp", &json!({"style": "nope"})).is_err());
|
||||
// Back to a mesh that keeps the arch's shape: the top middle point is where the warp put it.
|
||||
s.execute("object.envelope.resetWithWarp", &json!({"style": "arch", "bend": 50, "horizontal": true})).unwrap();
|
||||
let top = live::envelope_surface(&node(&s, e).children().unwrap().clone(), &kind(&s, e), 2, 2, vectorcraft_color::Color::BLACK).unwrap();
|
||||
let top = live::EnvelopeMap::of(&node(&s, e)).unwrap().surface_mesh(2, 2, vectorcraft_color::Color::BLACK);
|
||||
s.execute("object.envelope.resetWithMesh", &json!({"rows": 2, "cols": 2})).unwrap();
|
||||
let EnvelopeKind::Mesh { rows: 2, cols: 2, points } = kind(&s, e) else { panic!("{:?}", kind(&s, e)) };
|
||||
assert!(points[1].distance(top.points[1].p) < 1e-9 && points[1].y < 99.0, "{:?}", points[1]);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,298 @@
|
|||
//! What envelopes distort: the envelope's own frame under transforms, images (a raster mesh warp),
|
||||
//! symbols, linear gradients, pattern fills, strokes and effects (Distort Appearance).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Gradient, GradientPaint, Paint};
|
||||
use vectorcraft_doc::live::EnvelopeOptions;
|
||||
use vectorcraft_doc::{AppearanceItem, Node, NodeKind, PatternDef, Symbol};
|
||||
use vectorcraft_geom::{Affine, Point, Rect, shapes};
|
||||
use vectorcraft_render::effects;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn id_of(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId) -> Node {
|
||||
s.doc().unwrap().doc.node(id).unwrap().clone()
|
||||
}
|
||||
|
||||
fn sel(s: &mut Session, ids: &[NodeId]) {
|
||||
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
}
|
||||
|
||||
/// A 200×100 rectangle at (100, 100), filled red, no stroke.
|
||||
fn rect(s: &mut Session) -> NodeId {
|
||||
let a = id_of(&s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap());
|
||||
s.edit("red", |d, _| {
|
||||
d.node_mut(a).unwrap().appearance = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
a
|
||||
}
|
||||
|
||||
/// Envelope the selection with an arch warp (`bend` %) and `options` → the envelope.
|
||||
fn arch(s: &mut Session, bend: f64, options: Value) -> NodeId {
|
||||
let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arch", "bend": bend})).unwrap());
|
||||
s.execute("object.envelope.options", &options).unwrap();
|
||||
e
|
||||
}
|
||||
|
||||
/// The envelope evaluated as the canvas and the exporters do.
|
||||
fn evaluated(s: &Session, e: NodeId) -> Vec<Node> {
|
||||
effects::expand_live(Some(&s.doc().unwrap().doc), &node(s, e))
|
||||
}
|
||||
|
||||
fn anchors(nodes: &[Node]) -> Vec<Point> {
|
||||
let mut v = vec![];
|
||||
for n in nodes {
|
||||
n.walk(&mut |c| v.extend(c.path_data().into_iter().flat_map(|p| p.anchors().map(|(_, _, a)| a.p))));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn frame(s: &Session, e: NodeId) -> Affine {
|
||||
match node(s, e).kind {
|
||||
NodeKind::Envelope { frame, .. } => frame,
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression: rotating a warp envelope rotated its content and re-warped it along the page axes.
|
||||
#[test]
|
||||
fn a_rotated_warp_envelope_turns_with_its_warp() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s);
|
||||
sel(&mut s, &[a]);
|
||||
let e = arch(&mut s, 50.0, json!({}));
|
||||
let before = anchors(&evaluated(&s, e));
|
||||
let c = Point::new(200.0, 150.0);
|
||||
s.execute("object.rotate", &json!({"angle": 90, "origin": [c.x, c.y]})).unwrap();
|
||||
let after = anchors(&evaluated(&s, e));
|
||||
let turn = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, c);
|
||||
assert_eq!(before.len(), after.len());
|
||||
for (p, q) in before.iter().zip(&after) {
|
||||
assert!((turn * *p).distance(*q) < 1e-6, "{:?} vs {q:?}", turn * *p);
|
||||
}
|
||||
assert_ne!(frame(&s, e), Affine::IDENTITY);
|
||||
// Turning back squares the frame with the page again; a plain move or scale never tilts it.
|
||||
s.execute("object.rotate", &json!({"angle": -90, "origin": [c.x, c.y]})).unwrap();
|
||||
assert_eq!(frame(&s, e), Affine::IDENTITY);
|
||||
s.execute("object.scale", &json!({"sx": 150, "sy": 50})).unwrap();
|
||||
assert_eq!(frame(&s, e), Affine::IDENTITY);
|
||||
// A reflection keeps a frame: the arch flips with the art.
|
||||
s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap();
|
||||
let flipped = anchors(&evaluated(&s, e));
|
||||
let b = node(&s, e).geometric_bounds().unwrap();
|
||||
assert!(flipped.iter().any(|p| p.y > b.y1 - 1.0) && frame(&s, e) != Affine::IDENTITY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_frame_round_trips_natively() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s);
|
||||
sel(&mut s, &[a]);
|
||||
let e = arch(&mut s, 50.0, json!({}));
|
||||
s.execute("object.rotate", &json!({"angle": 30})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap();
|
||||
assert_eq!(back.node(e), d.node(e));
|
||||
let mut old = serde_json::to_value(node(&s, e)).unwrap();
|
||||
old["kind"].as_object_mut().unwrap().remove("frame");
|
||||
let n: Node = serde_json::from_value(old).unwrap();
|
||||
assert!(matches!(n.kind, NodeKind::Envelope { frame, .. } if frame == Affine::IDENTITY), "older envelopes stand square to the page");
|
||||
}
|
||||
|
||||
/// A selected embedded 40×40 pt red image at (100, 100).
|
||||
fn red_image(s: &mut Session) -> NodeId {
|
||||
let img = image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255]));
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
s.edit("Place", |d, sel| {
|
||||
d.images.insert("pic".into(), vectorcraft_doc::ImageBlob::png(png));
|
||||
let id = d.alloc_id();
|
||||
let xf = Affine::translate((100.0, 100.0)) * Affine::scale(10.0);
|
||||
let im = vectorcraft_doc::ImageObject { key: "pic".into(), width: 4, height: 4, xf, link: None, placement: Default::default() };
|
||||
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(im)))?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_bend_as_a_raster_mesh_warp() {
|
||||
let mut s = session();
|
||||
let im = red_image(&mut s);
|
||||
sel(&mut s, &[im]);
|
||||
let e = arch(&mut s, 80.0, json!({}));
|
||||
let out = evaluated(&s, e);
|
||||
let mut pieces = 0;
|
||||
out[0].walk(&mut |c| pieces += matches!(c.kind, NodeKind::Image(_)) as usize);
|
||||
assert!(pieces >= 32, "{pieces} pieces");
|
||||
// The arch lifts the image's middle above its top edge, not its corners.
|
||||
let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true);
|
||||
let b = node(&s, e).geometric_bounds().unwrap();
|
||||
assert!(b.y0 < 95.0, "{b:?}");
|
||||
let mid = img.pixel(120, 97);
|
||||
assert!(mid[0] > 200 && mid[1] < 60, "the middle rose: {mid:?}");
|
||||
assert!(img.pixel(101, 95)[1] > 200, "the corner stayed: {:?}", img.pixel(101, 95));
|
||||
// The pieces export as clipped images, bent in the SVG too.
|
||||
let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap()["dataBase64"].as_str().unwrap().to_string();
|
||||
let svg = String::from_utf8(vectorcraft_format::base64_decode(&svg).unwrap()).unwrap();
|
||||
assert!(svg.matches("<use").count() >= 32 && svg.contains("clip"), "{}", &svg[..svg.len().min(400)]);
|
||||
assert_eq!(svg.matches("<image").count(), 1, "the pieces share the image's pixels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbols_bend_with_the_envelope() {
|
||||
let mut s = session();
|
||||
let inst = s
|
||||
.edit("symbol", |d, sel| {
|
||||
// Symbol art is normalised to ±10 (its instance's transform sizes it).
|
||||
let art = Node::path(
|
||||
NodeId(0),
|
||||
shapes::rectangle(Rect::new(-10.0, -10.0, 10.0, 10.0)),
|
||||
vectorcraft_doc::Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
|
||||
);
|
||||
d.symbols.push(Symbol { name: "Box".into(), art: Arc::new(art) });
|
||||
let id = d.alloc_id();
|
||||
let xf = Affine::translate((200.0, 150.0)) * Affine::scale_non_uniform(10.0, 5.0);
|
||||
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Box".into(), xf }))?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap();
|
||||
sel(&mut s, &[inst]);
|
||||
let e = arch(&mut s, 60.0, json!({}));
|
||||
let pts = anchors(&evaluated(&s, e));
|
||||
assert!(pts.iter().any(|p| p.y < 90.0), "the symbol's art rose in the middle");
|
||||
// Editing the symbol redraws the envelope (the renderer's cache follows the symbol's art).
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let region = Rect::new(0.0, 0.0, 400.0, 300.0);
|
||||
assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 150)[1] < 60);
|
||||
s.edit("edit symbol", |d, _| {
|
||||
let art = Node::path(NodeId(0), shapes::rectangle(Rect::new(-10.0, -10.0, -8.0, -8.0)), Default::default());
|
||||
d.symbols[0].art = Arc::new(art);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 150)[1] > 200);
|
||||
}
|
||||
|
||||
fn strokes(nodes: &[Node]) -> usize {
|
||||
let mut n = 0;
|
||||
for c in nodes {
|
||||
c.walk(&mut |c| n += c.appearance.items.iter().filter(|i| matches!(i, AppearanceItem::Stroke(_))).count());
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distort_appearance_bends_strokes_and_effects_with_the_art() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s);
|
||||
s.execute("paint.setStroke", &json!({"ids": [a.0], "color": "#000000"})).unwrap();
|
||||
s.execute("stroke.set", &json!({"ids": [a.0], "weight": 8})).unwrap();
|
||||
sel(&mut s, &[a]);
|
||||
let e = arch(&mut s, 60.0, json!({"distortAppearance": true}));
|
||||
let out = evaluated(&s, e);
|
||||
assert_eq!(strokes(&out), 0, "the stroke became its bent outline");
|
||||
// Off: the stroke paints along the bent path at its own weight.
|
||||
s.execute("object.envelope.options", &json!({"distortAppearance": false})).unwrap();
|
||||
assert_eq!(strokes(&evaluated(&s, e)), 1);
|
||||
// Geometry effects bake into the art first too.
|
||||
s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": "distort.zigZag", "params": {"size": 5, "ridges": 10}})).unwrap();
|
||||
s.execute("object.envelope.editContents", &json!({"editing": false})).unwrap();
|
||||
s.execute("object.envelope.options", &json!({"distortAppearance": true})).unwrap();
|
||||
let out = evaluated(&s, e);
|
||||
let mut effects_left = 0;
|
||||
out[0].walk(&mut |c| effects_left += c.appearance.effects.len());
|
||||
assert_eq!(effects_left, 0);
|
||||
assert!(anchors(&out).len() > anchors(&[node(&s, a)]).len());
|
||||
}
|
||||
|
||||
fn gradient_geom(nodes: &[Node]) -> Option<vectorcraft_color::GradientGeom> {
|
||||
let mut g = None;
|
||||
for n in nodes {
|
||||
n.walk(&mut |c| {
|
||||
if let Paint::Gradient(gp) = c.appearance.fill_paint() {
|
||||
g = g.or(gp.geom);
|
||||
}
|
||||
});
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_gradients_bend_when_asked() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s);
|
||||
s.edit("gradient", |d, _| {
|
||||
d.node_mut(a).unwrap().appearance.set_fill(Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
sel(&mut s, &[a]);
|
||||
let e = arch(&mut s, 60.0, json!({"distortAppearance": true}));
|
||||
assert!(gradient_geom(&evaluated(&s, e)).is_none(), "off: the gradient fits the bent shape");
|
||||
s.execute("object.envelope.options", &json!({"distortLinearGradients": true})).unwrap();
|
||||
let g = gradient_geom(&evaluated(&s, e)).expect("on: the gradient is placed and follows the warp");
|
||||
// Arch lifts the middle: the vector (left to right through the middle) moved up.
|
||||
assert!(g.start.midpoint(g.end).y < 150.0 - 1.0, "{g:?}");
|
||||
// It needs Distort Appearance.
|
||||
s.execute("object.envelope.options", &json!({"distortAppearance": false})).unwrap();
|
||||
assert!(gradient_geom(&evaluated(&s, e)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_fills_bend_when_asked() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s);
|
||||
s.edit("pattern", |d, _| {
|
||||
let dot = Node::path(
|
||||
NodeId(0),
|
||||
shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)),
|
||||
vectorcraft_doc::Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
|
||||
);
|
||||
let mut def = PatternDef::new("Dots", vec![Arc::new(dot)]);
|
||||
def.tile = Rect::new(0.0, 0.0, 20.0, 20.0);
|
||||
d.patterns.push(def);
|
||||
d.node_mut(a).unwrap().appearance.set_fill(vectorcraft_doc::pattern::pattern_paint("Dots"));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
sel(&mut s, &[a]);
|
||||
let e = arch(&mut s, 60.0, json!({"distortAppearance": true}));
|
||||
let clip_groups = |out: &[Node]| {
|
||||
let mut n = 0;
|
||||
out[0].walk(&mut |c| n += matches!(c.kind, NodeKind::Group { clip: true, .. }) as usize);
|
||||
n
|
||||
};
|
||||
assert_eq!(clip_groups(&evaluated(&s, e)), 0, "off: the pattern fills the bent shape unbent");
|
||||
s.execute("object.envelope.options", &json!({"distortPatternFills": true})).unwrap();
|
||||
let out = evaluated(&s, e);
|
||||
assert_eq!(clip_groups(&out), 1);
|
||||
assert!(anchors(&out).len() > 50 * 4, "the tiles are art");
|
||||
assert!(!matches!(out[0].appearance.fill_paint(), Paint::Pattern { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_envelopes_distort_appearance_and_old_ones_keep_their_look() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s);
|
||||
sel(&mut s, &[a]);
|
||||
let e = arch(&mut s, 50.0, json!({}));
|
||||
assert!(matches!(node(&s, e).kind, NodeKind::Envelope { options, .. } if options == EnvelopeOptions::NEW));
|
||||
}
|
||||
|
|
@ -399,8 +399,10 @@ fn envelope_release_and_expand() {
|
|||
s.execute("object.envelope.expand", &json!({})).unwrap();
|
||||
let g = node(&s, e);
|
||||
assert!(matches!(g.kind, NodeKind::Group { .. }));
|
||||
let p = g.children().unwrap()[0].path_data().unwrap().anchor_count();
|
||||
assert!(p > 4);
|
||||
// (Distort Appearance, on for new envelopes, expands the stroke too: the fill comes first.)
|
||||
let mut fill = None;
|
||||
g.walk(&mut |c| fill = fill.or_else(|| c.path_data().map(|p| p.anchor_count())));
|
||||
assert!(fill.unwrap() > 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -292,7 +292,7 @@ impl<'a> Scene<'a> {
|
|||
}
|
||||
// Live blends, envelopes, meshes and repeats are written as their evaluated art.
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
|
||||
let g = vectorcraft_effects::expand_live_deep(n);
|
||||
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
|
||||
for c in g.children().into_iter().flatten() {
|
||||
self.node(c, false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ impl<'a> Scene<'a> {
|
|||
}
|
||||
// Live blends, envelopes, meshes and repeats are written as their evaluated art.
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
|
||||
let g = vectorcraft_effects::expand_live_deep(n);
|
||||
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
|
||||
for c in g.children().into_iter().flatten() {
|
||||
self.node(c, false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -983,7 +983,7 @@ impl Exporter<'_> {
|
|||
}
|
||||
// Live blends/envelopes/meshes export their evaluated (expanded) form.
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
|
||||
let g = vectorcraft_effects::expand_live_deep(n);
|
||||
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
|
||||
for c in g.children().into_iter().flatten() {
|
||||
self.node(s, c, page, false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, ®ion.0)
|
||||
{
|
||||
self.draw_image_in(ctx, f, im, Some(®ion));
|
||||
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((®ion.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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
|
|||
symbols: HashMap::new(),
|
||||
symbol_nest: 0,
|
||||
fonts: Vec::new(),
|
||||
shared_images: HashMap::new(),
|
||||
};
|
||||
w.assign_name_ids();
|
||||
// Page Isolated Blending / Page Knockout Group: the page content is one isolated group.
|
||||
|
|
@ -247,6 +248,9 @@ struct Writer<'a> {
|
|||
symbol_nest: u32,
|
||||
/// The characters type uses from each face ([`ExportOptions::embed_fonts`]).
|
||||
fonts: Vec<FontUse>,
|
||||
/// Images written once in the defs and drawn with `<use>`, by key and size: the pieces an
|
||||
/// envelope cuts a distorted image into share its pixels.
|
||||
shared_images: HashMap<(String, u32, u32), String>,
|
||||
}
|
||||
|
||||
/// The characters type uses from one face, under one `@font-face` description: the family the
|
||||
|
|
@ -433,6 +437,58 @@ impl Writer<'_> {
|
|||
self.defs.push('\n');
|
||||
}
|
||||
}
|
||||
/// The `href` of image `im`: its file with [`ImageMode::Link`] (or when its pixels are
|
||||
/// missing), else its bytes. `None` when it has neither.
|
||||
fn image_href(&mut self, im: &vectorcraft_doc::ImageObject) -> Option<String> {
|
||||
let (doc, link) = (self.doc, self.opts.images == ImageMode::Link);
|
||||
match (im.link.as_ref().filter(|_| link), doc.images.get(&im.key)) {
|
||||
(Some(l), _) => Some(l.path.clone()),
|
||||
(None, Some(b)) if !b.bytes.is_empty() => Some(self.blob_href(b)),
|
||||
_ => im.link.as_ref().map(|l| l.path.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Images drawn more than once in `art` (the pieces of a distorted image) go into the defs
|
||||
/// once; each piece then draws them with `<use>`.
|
||||
fn share_images(&mut self, art: &Node) {
|
||||
let mut seen: HashMap<(String, u32, u32), usize> = HashMap::new();
|
||||
art.walk(&mut |c| {
|
||||
if let NodeKind::Image(im) = &c.kind {
|
||||
*seen.entry((im.key.clone(), im.width, im.height)).or_default() += 1;
|
||||
}
|
||||
});
|
||||
let mut repeated: Vec<_> = seen.into_iter().filter(|(k, n)| *n > 1 && !self.shared_images.contains_key(k)).map(|(k, _)| k).collect();
|
||||
repeated.sort();
|
||||
for (key, width, height) in repeated {
|
||||
let link = self.linked_of(art, &key);
|
||||
let im = vectorcraft_doc::ImageObject { key: key.clone(), width, height, xf: Affine::IDENTITY, link, placement: Default::default() };
|
||||
let Some(href) = self.image_href(&im) else { continue };
|
||||
let id = self.fresh_id("image");
|
||||
self.def(
|
||||
0,
|
||||
&format!(
|
||||
"<image id=\"{id}\" width=\"{width}\" height=\"{height}\" preserveAspectRatio=\"none\" xlink:href=\"{}\"/>",
|
||||
xml_escape(&href)
|
||||
),
|
||||
);
|
||||
self.shared_images.insert((key, width, height), id);
|
||||
}
|
||||
}
|
||||
|
||||
/// The link of the first image with blob `key` in `art`.
|
||||
fn linked_of(&self, art: &Node, key: &str) -> Option<vectorcraft_doc::LinkInfo> {
|
||||
let mut link = None;
|
||||
art.walk(&mut |c| {
|
||||
if let NodeKind::Image(im) = &c.kind
|
||||
&& im.key == key
|
||||
&& link.is_none()
|
||||
{
|
||||
link = im.link.clone();
|
||||
}
|
||||
});
|
||||
link
|
||||
}
|
||||
|
||||
fn fresh_id(&mut self, prefix: &str) -> String {
|
||||
let mut i = 1;
|
||||
loop {
|
||||
|
|
@ -1233,18 +1289,15 @@ impl Writer<'_> {
|
|||
}
|
||||
NodeKind::Text(t) => self.text_node(n, t),
|
||||
NodeKind::Image(im) => {
|
||||
let (doc, link) = (self.doc, self.opts.images == ImageMode::Link);
|
||||
let href = match (im.link.as_ref().filter(|_| link), doc.images.get(&im.key)) {
|
||||
(Some(l), _) => l.path.clone(),
|
||||
(None, Some(b)) if !b.bytes.is_empty() => self.blob_href(b),
|
||||
_ => match &im.link {
|
||||
Some(l) => l.path.clone(),
|
||||
None => return,
|
||||
},
|
||||
};
|
||||
let id = self.id_attr(n);
|
||||
let m = self.matrix(self.xf * im.xf);
|
||||
let a = self.attrs(&css::transparency(n));
|
||||
if let Some(shared) = self.shared_images.get(&(im.key.clone(), im.width, im.height)) {
|
||||
let shared = shared.clone();
|
||||
self.line(&format!("<use{id} transform=\"{m}\"{a} xlink:href=\"#{shared}\"/>"));
|
||||
return;
|
||||
}
|
||||
let Some(href) = self.image_href(im) else { return };
|
||||
self.line(&format!(
|
||||
"<image{id} width=\"{}\" height=\"{}\" transform=\"{m}\" preserveAspectRatio=\"none\"{a} xlink:href=\"{}\"/>",
|
||||
im.width,
|
||||
|
|
@ -1290,7 +1343,8 @@ impl Writer<'_> {
|
|||
}
|
||||
// Live blends/envelopes/meshes export their evaluated (expanded) form.
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
|
||||
let g = vectorcraft_effects::expand_live_deep(n);
|
||||
let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n);
|
||||
self.share_images(&g);
|
||||
self.node_body(&g);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ mod width;
|
|||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use crate::Tool;
|
||||
|
||||
|
|
@ -79,14 +79,7 @@ pub fn warp_node_with(n: &mut Node, f: &dyn Fn(Point) -> Point) {
|
|||
n.appearance.warp_gradients(&|p| affine_near(f, p, eps));
|
||||
}
|
||||
|
||||
/// The affine map that best matches `f` near `p` (finite differences with step `eps`).
|
||||
pub fn affine_near(f: &dyn Fn(Point) -> Point, p: Point, eps: f64) -> Affine {
|
||||
let o = f(p);
|
||||
let ex = (f(p + Vec2::new(eps, 0.0)) - f(p - Vec2::new(eps, 0.0))) / (2.0 * eps);
|
||||
let ey = (f(p + Vec2::new(0.0, eps)) - f(p - Vec2::new(0.0, eps))) / (2.0 * eps);
|
||||
let lin = Affine::new([ex.x, ex.y, ey.x, ey.y, 0.0, 0.0]);
|
||||
Affine::translate(o.to_vec2()) * lin * Affine::translate(-p.to_vec2())
|
||||
}
|
||||
pub use vectorcraft_doc::live::affine_near;
|
||||
|
||||
/// Every anchor and handle position in `n` (for meshes and bounds).
|
||||
pub fn collect_points(n: &Node, out: &mut Vec<Point>) {
|
||||
|
|
@ -127,7 +120,7 @@ mod tests {
|
|||
use super::*;
|
||||
use vectorcraft_color::{Gradient, GradientPaint, Paint};
|
||||
use vectorcraft_doc::Appearance;
|
||||
use vectorcraft_geom::{PathData, Rect, shapes};
|
||||
use vectorcraft_geom::{Affine, PathData, Rect, shapes};
|
||||
|
||||
#[test]
|
||||
fn create_covers_all_distort_tools() {
|
||||
|
|
|
|||
|
|
@ -1829,6 +1829,12 @@ envelopes, the path for a top-object envelope); the content keeps the envelope's
|
|||
opacity mask.
|
||||
Type inside an envelope distorts as its glyph outlines, run by run in each run's paint, on the canvas and in every
|
||||
export (SVG, PDF, EPS, EMF/WMF, DXF).
|
||||
Everything inside bends with the envelope: images as a raster mesh warp (cut into pieces by Fidelity, each a clipped
|
||||
image under its own affine map; SVG shares the pixels between the pieces), symbol instances as their symbol's art,
|
||||
and with Distort Appearance strokes (as their filled outlines) and geometry effects; with it, Distort Linear Gradients
|
||||
bends linear gradients and Distort Pattern Fills the pattern tiles. A transformed envelope keeps its warp in its own
|
||||
frame (`kind.frame` in `document.inspect`; rotating a warp envelope turns the warp with it), and move and scale keep
|
||||
it square to the page.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"object.envelope.makeWithWarp","params":{"style":"arch","bend":40}}}
|
||||
|
|
|
|||
Loading…
Reference in New Issue