mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 05:24:44 +00:00
Envelopes: a partly transparent image keeps its alpha where its warp pieces overlap
An envelope or the Warp effect cuts an image into triangle pieces that reach a little past their edges, so neighbors overlap and no seams show. On an image with alpha, the overlap drew that alpha twice: a 50% image came out about 75% opaque along every cell edge and diagonal. - The pieces now form a knockout group, and each piece is marked as a warp piece (`warp_piece`) whose knockout shape is its whole triangle, so where pieces overlap only the top one draws. - The canvas knocks each piece out by its triangle at full coverage. - SVG and PDF draw the pieces of a partly transparent image (its pixels, opacity, color mask or colorize paint) through one mask each, of the later triangles that reach it; pixels on a triangle's antialiased edge come out slightly lighter there. Pieces of an opaque image are drawn one over another as before. - SVG writes one copy of the image for pieces cut by opacity masks (Preserve Shape Using Transparency) too. - A cell count past the generated-object budget makes an empty group instead of overflowing. Docs: docs/target-app-parity.md.
This commit is contained in:
@@ -1247,4 +1247,60 @@ mod tests {
|
||||
assert_eq!(m, before);
|
||||
}
|
||||
}
|
||||
|
||||
/// The pieces of a mesh-warped image make a knockout group, each knocking out its triangle
|
||||
/// (clipping path or opacity mask art); the group keeps the image's transparency.
|
||||
#[test]
|
||||
fn mesh_warp_image_pieces_knock_out_by_their_triangles() {
|
||||
let im = crate::ImageObject {
|
||||
key: "pic".into(),
|
||||
width: 4,
|
||||
height: 4,
|
||||
xf: Affine::scale(10.0),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
let mut n = Node::new(NodeId(3), NodeKind::Image(Box::new(im.clone())));
|
||||
n.opacity = 0.5;
|
||||
n.blend = crate::color::BlendMode::Multiply;
|
||||
let same = |p: Point| p;
|
||||
for masked in [false, true] {
|
||||
let g = mesh_warp_image(&n, &im, &same, 2, false, masked);
|
||||
assert_eq!((g.knockout, g.opacity, g.blend, g.isolate), (crate::Knockout::On, 0.5, crate::color::BlendMode::Multiply, false));
|
||||
let NodeKind::Group { children, clip: false } = &g.kind else { panic!("a group") };
|
||||
assert_eq!(children.len(), 8);
|
||||
for c in children {
|
||||
assert!(c.extras().warp_piece);
|
||||
let outline = c.knockout_outline().unwrap_or_else(|| panic!("masked {masked}: an outline"));
|
||||
let NodeKind::Path { path, .. } = &outline.kind else { panic!("a path") };
|
||||
assert_eq!(path.anchor_count(), 3);
|
||||
}
|
||||
}
|
||||
// A clip group or masked image that is not a piece knocks out by what it paints.
|
||||
let mut plain = mesh_warp_image(&n, &im, &same, 1, false, false);
|
||||
let NodeKind::Group { children, .. } = &mut plain.kind else { panic!("a group") };
|
||||
let piece = Arc::make_mut(&mut children[0]);
|
||||
piece.extras_mut().warp_piece = false;
|
||||
assert!(piece.knockout_outline().is_none());
|
||||
}
|
||||
|
||||
/// More cells than the objects live art may make cut no pieces (the count overflowed).
|
||||
#[test]
|
||||
fn mesh_warp_image_past_the_budget_makes_an_empty_group() {
|
||||
let im = crate::ImageObject {
|
||||
key: "pic".into(),
|
||||
width: 4,
|
||||
height: 4,
|
||||
xf: Affine::IDENTITY,
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
let n = Node::new(NodeId(3), NodeKind::Image(Box::new(im.clone())));
|
||||
for cells in [usize::MAX, 1000] {
|
||||
let g = mesh_warp_image(&n, &im, &|p| p, cells, false, false);
|
||||
assert!(matches!(&g.kind, NodeKind::Group { children, .. } if children.is_empty()), "{cells}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,9 +207,21 @@ pub fn mesh_warp_cost(cells: usize) -> usize {
|
||||
/// triangle instead; with `pixelated`, drawn without smoothing). Each piece reaches a little past
|
||||
/// its edges (at most half a point) so neighbors overlap and no seams show. Each piece keeps the
|
||||
/// color `n` colorizes a grayscale image with, and `n`'s color mask. The result is a group with `n`'s id, name,
|
||||
/// transparency, opacity mask and appearance.
|
||||
/// transparency, opacity mask and appearance. It is a knockout group and each piece is a
|
||||
/// [`crate::NodeExtras::warp_piece`], so where pieces overlap only the top one draws and a
|
||||
/// partly transparent image keeps its own alpha there. More cells than [`MAX_GENERATED`] objects
|
||||
/// allow make an empty group.
|
||||
pub fn mesh_warp_image(n: &Node, im: &ImageObject, f: &dyn Fn(Point) -> Point, cells: usize, pixelated: bool, masked: bool) -> Node {
|
||||
let k = cells.max(1);
|
||||
let mut g = n.clone();
|
||||
g.kind = NodeKind::Group { children: vec![], clip: false };
|
||||
g.knockout = crate::Knockout::On;
|
||||
if let Some(e) = g.extras_if_any() {
|
||||
e.colorize = None;
|
||||
}
|
||||
if mesh_warp_cost(k) > MAX_GENERATED {
|
||||
return g;
|
||||
}
|
||||
// A grayscale image's fill color colorizes each piece; the group of pieces takes none.
|
||||
let colorize = n.extras().colorize.clone();
|
||||
// Each piece shows what the image shows: its color mask's masked pixels.
|
||||
@@ -243,22 +255,22 @@ pub fn mesh_warp_image(n: &Node, im: &ImageObject, f: &dyn Fn(Point) -> Point, c
|
||||
// Preserve Shape Using Transparency: the triangle is the piece's alpha (a
|
||||
// white opacity mask) instead of its clipping path.
|
||||
let white = Node::path(n.id, shape, Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0));
|
||||
pieces.push(Arc::new(image.with_mask(Some(Box::new(crate::OpacityMask::new(white, true))))));
|
||||
let mut piece = image.with_mask(Some(Box::new(crate::OpacityMask::new(white, true))));
|
||||
piece.extras_mut().warp_piece = true;
|
||||
pieces.push(Arc::new(piece));
|
||||
continue;
|
||||
}
|
||||
let mut clip = Node::path(n.id, shape, Appearance::default());
|
||||
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
pieces.push(Arc::new(Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true })));
|
||||
let mut piece = Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true });
|
||||
piece.extras_mut().warp_piece = true;
|
||||
pieces.push(Arc::new(piece));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut g = n.clone();
|
||||
g.kind = NodeKind::Group { children: pieces, clip: false };
|
||||
if let Some(e) = g.extras_if_any() {
|
||||
e.colorize = None;
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,10 @@ pub struct NodeExtras {
|
||||
/// Image Tools › Color Mask: the colors an image hides while its pixels stay as they are
|
||||
/// ([`crate::color_mask`]).
|
||||
pub color_mask: Option<Box<crate::color_mask::ColorMask>>,
|
||||
/// A piece of an image a raster mesh warp cut up ([`crate::live::mesh_warp_image`]): in a
|
||||
/// knockout group its knockout shape is the whole area of its triangle, not only where the
|
||||
/// image is opaque ([`Node::knockout_outline`]).
|
||||
pub warp_piece: bool,
|
||||
}
|
||||
|
||||
/// No extras: what [`Node::extras`] gives for a node without any.
|
||||
@@ -103,6 +107,7 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras {
|
||||
envelope_edit_margin: None,
|
||||
pixel_aligned: false,
|
||||
color_mask: None,
|
||||
warp_piece: false,
|
||||
};
|
||||
|
||||
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
|
||||
|
||||
@@ -716,6 +716,37 @@ impl Node {
|
||||
out
|
||||
}
|
||||
|
||||
/// As a piece of a mesh-warped image ([`crate::NodeExtras::warp_piece`]), the art whose area is its
|
||||
/// knockout shape at full coverage: the art of its opacity mask (on and not inverted), or the
|
||||
/// clipping path of its clip group. `None` for other objects, whose knockout shape is what they
|
||||
/// paint.
|
||||
pub fn knockout_outline(&self) -> Option<&Arc<Node>> {
|
||||
let e = self.extras();
|
||||
if !e.warp_piece {
|
||||
return None;
|
||||
}
|
||||
if let Some(m) = e.mask.as_deref() {
|
||||
return (!m.disabled && !m.invert).then_some(&m.art);
|
||||
}
|
||||
match &self.kind {
|
||||
NodeKind::Group { children, clip: true } => children.first(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The image a piece of a mesh-warped image draws ([`Self::knockout_outline`]): the piece itself
|
||||
/// when an opacity mask cuts it, else the image its clip group clips. `None` for other objects.
|
||||
pub fn warp_piece_image(&self) -> Option<&Node> {
|
||||
if !self.extras().warp_piece {
|
||||
return None;
|
||||
}
|
||||
match &self.kind {
|
||||
NodeKind::Image(_) => Some(self),
|
||||
NodeKind::Group { children, clip: true } => children.get(1).map(|c| &**c).filter(|c| matches!(c.kind, NodeKind::Image(_))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this is a clip group or a layer with a clipping mask: its first child clips the rest.
|
||||
/// A layer whose first child is a ruler guide holds no art, so a guide never clips it.
|
||||
pub fn clips(&self) -> bool {
|
||||
|
||||
@@ -23,6 +23,7 @@ fn extras_are_written_flat_in_order() {
|
||||
e.shape_mode = crate::ShapeMode::Exclude;
|
||||
e.envelope_edit_margin = Some([0.5, 0.5, 0.25, 0.25]);
|
||||
e.pixel_aligned = true;
|
||||
e.warp_piece = true;
|
||||
}
|
||||
/// The top-level keys of a JSON object, in the order written.
|
||||
struct Keys(Vec<String>);
|
||||
@@ -69,10 +70,12 @@ fn extras_are_written_flat_in_order() {
|
||||
"shape_mode",
|
||||
"envelope_edit_margin",
|
||||
"pixel_aligned",
|
||||
"warp_piece",
|
||||
];
|
||||
assert_eq!(keys, order);
|
||||
assert_eq!(v["shape_mode"], "exclude");
|
||||
assert_eq!(v["pixel_aligned"], true);
|
||||
assert_eq!(v["warp_piece"], true);
|
||||
assert_eq!(serde_json::from_value::<Node>(v).unwrap(), n);
|
||||
let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art());
|
||||
let back: Node = serde_json::from_value(serde_json::to_value(&plain).unwrap()).unwrap();
|
||||
|
||||
@@ -56,6 +56,7 @@ impl Serialize for Node {
|
||||
e.envelope_edit_margin.is_none(),
|
||||
is_default(&e.pixel_aligned),
|
||||
e.color_mask.is_none(),
|
||||
is_default(&e.warp_piece),
|
||||
];
|
||||
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
|
||||
let mut skip = skips.into_iter();
|
||||
@@ -100,6 +101,7 @@ impl Serialize for Node {
|
||||
field!("envelope_edit_margin", &e.envelope_edit_margin);
|
||||
field!("pixel_aligned", &e.pixel_aligned);
|
||||
field!("color_mask", &e.color_mask);
|
||||
field!("warp_piece", &e.warp_piece);
|
||||
st.end()
|
||||
}
|
||||
}
|
||||
@@ -245,6 +247,8 @@ pub(super) struct NodeWire {
|
||||
pixel_aligned: bool,
|
||||
#[serde(default)]
|
||||
color_mask: Option<Box<crate::color_mask::ColorMask>>,
|
||||
#[serde(default)]
|
||||
warp_piece: bool,
|
||||
}
|
||||
|
||||
impl From<NodeWire> for Node {
|
||||
@@ -274,6 +278,7 @@ impl From<NodeWire> for Node {
|
||||
envelope_edit_margin: w.envelope_edit_margin,
|
||||
pixel_aligned: w.pixel_aligned,
|
||||
color_mask: w.color_mask,
|
||||
warp_piece: w.warp_piece,
|
||||
};
|
||||
Node {
|
||||
id: w.id,
|
||||
|
||||
@@ -266,6 +266,12 @@ impl ImageBlob {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Whether any pixel of the image is less than fully opaque. `false` when the image can't be
|
||||
/// decoded. The last image decoded is kept, as for [`Self::sample`].
|
||||
pub fn has_transparency(&self) -> bool {
|
||||
self.decoded().is_some_and(|img| img.pixels().any(|p| p.0[3] < 255))
|
||||
}
|
||||
|
||||
/// The decoded pixels (the last image decoded is kept for the next sample).
|
||||
fn decoded(&self) -> Option<Arc<image::RgbaImage>> {
|
||||
thread_local! {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! The region a clip group clips to ([`Node::clip_outline`]) with text outlined by the font engine
|
||||
//! and several shapes united, so the renderer and the SVG and PDF writers clip alike.
|
||||
|
||||
use vectorcraft_doc::{Appearance, Node, NodeKind};
|
||||
use vectorcraft_doc::color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeKind};
|
||||
use vectorcraft_geom::{BezPath, FillRule, PathData};
|
||||
|
||||
/// The clipping path `clip` of a clip group as one path and fill rule: compound paths keep their
|
||||
@@ -11,6 +12,54 @@ pub fn clip_outline(clip: &Node) -> Option<(BezPath, FillRule)> {
|
||||
clip.clip_outline(Some(&outline_text), &unite)
|
||||
}
|
||||
|
||||
/// The knockout shape of a piece of a mesh-warped image ([`Node::knockout_outline`]) painted opaque
|
||||
/// in `color`, which the SVG and PDF writers put in their knockout masks: for a clip group, a
|
||||
/// rectangle clipped by the same clipping path (so a viewer covers the same pixels of its edges as
|
||||
/// when it clips the piece), else the path of its opacity mask's art, filled. `None` for other
|
||||
/// objects and for a piece with nothing to clip by.
|
||||
pub fn knockout_silhouette(n: &Node, color: Color) -> Option<Node> {
|
||||
let outline = n.knockout_outline()?;
|
||||
let (bp, rule) = clip_outline(outline)?;
|
||||
let paint = Appearance::basic(Paint::solid(color), Paint::None, 0.0);
|
||||
if n.clips() && n.extras().mask.is_none() {
|
||||
let cover = Node::path(n.id, vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Shape::bounding_box(&bp).inflate(1.0, 1.0)), paint);
|
||||
let children = vec![outline.clone(), std::sync::Arc::new(cover)];
|
||||
return Some(Node::new(n.id, NodeKind::Group { children, clip: true }));
|
||||
}
|
||||
let mut p = Node::path(n.id, PathData::from_bezpath(&bp), paint);
|
||||
if let NodeKind::Path { rule: r, .. } = &mut p.kind {
|
||||
*r = rule;
|
||||
}
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// Whether any of `pieces`, pieces of a mesh-warped image ([`Node::warp_piece_image`]), paints less
|
||||
/// than opaque anywhere inside its triangle: it or its image is partly transparent, a color mask
|
||||
/// clears some of its colors, a colorize paint draws its light pixels partly transparent, or the
|
||||
/// pixels of its image (looked up in `doc`) are. Each distinct
|
||||
/// image's pixels are scanned once. Pieces of an opaque image overlap without showing it, so the SVG
|
||||
/// and PDF writers draw them one over another without masks.
|
||||
pub fn warp_pieces_translucent(doc: &Document, pieces: &[&Node]) -> bool {
|
||||
let mut scanned: Vec<*const vectorcraft_doc::ImageBlob> = vec![];
|
||||
for n in pieces {
|
||||
let Some(image) = n.warp_piece_image() else { continue };
|
||||
let NodeKind::Image(im) = &image.kind else { continue };
|
||||
let e = image.extras();
|
||||
if n.opacity < 1.0 || image.opacity < 1.0 || e.color_mask.is_some() || e.colorize.is_some() {
|
||||
return true;
|
||||
}
|
||||
let Some(blob) = doc.image_blob(im) else { continue };
|
||||
if scanned.contains(&std::ptr::from_ref(blob)) {
|
||||
continue;
|
||||
}
|
||||
scanned.push(std::ptr::from_ref(blob));
|
||||
if blob.has_transparency() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Text as one path of its glyph outlines, in document space.
|
||||
fn outline_text(n: &Node) -> Option<Node> {
|
||||
let NodeKind::Text(t) = &n.kind else { return None };
|
||||
|
||||
@@ -69,7 +69,7 @@ pub use adjust::{
|
||||
is_channel,
|
||||
};
|
||||
pub use bake::{StrokeArt, bake_appearance, bake_document, bake_type_appearance, expand_art, expand_leaf, fresh_ids, needs_bake};
|
||||
pub use clip::clip_outline;
|
||||
pub use clip::{clip_outline, knockout_silhouette, warp_pieces_translucent};
|
||||
pub use group::{
|
||||
OutlineHook, PATHFINDER_EFFECTS, compound_shape_area, compound_shape_art, evaluate_container, has_container_appearance, has_pathfinder,
|
||||
is_pathfinder, member_leaves, member_shapes, paints, pathfinder_children, shape_art,
|
||||
|
||||
@@ -291,3 +291,58 @@ fn a_warp_on_a_group_bends_the_image_in_it() {
|
||||
s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"bend": 50}})).unwrap();
|
||||
assert_eq!(wrong(&drawn(&s), &|u, v| arced(50.0, u, v)), vec![]);
|
||||
}
|
||||
|
||||
/// A selected embedded 40×40 px red image at half alpha filling [`FRAME`], with Effect › Warp ›
|
||||
/// Arc at 50 %.
|
||||
fn half_clear_arc(s: &mut Session) {
|
||||
let img = image::RgbaImage::from_pixel(40, 40, image::Rgba([255, 0, 0, 128]));
|
||||
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("half".into(), vectorcraft_doc::ImageBlob::png(png));
|
||||
let id = d.alloc_id();
|
||||
let xf = Affine::translate((FRAME.x0, FRAME.y0)) * Affine::scale(2.0);
|
||||
let im =
|
||||
vectorcraft_doc::ImageObject { key: "half".into(), width: 40, height: 40, xf, link: None, placement: Default::default(), pixels: None };
|
||||
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": "warp.arc", "params": {"bend": 50}})).unwrap();
|
||||
}
|
||||
|
||||
/// The points of a 50 × 50 grid over the bent image of [`half_clear_arc`], away from its outer
|
||||
/// edges, where the document drawn on white at 8 px a point is not half red (its green is not in
|
||||
/// `green`; half red over white is (255, 128, 128)): the first few.
|
||||
fn not_half_red(s: &Session, green: std::ops::RangeInclusive<u8>) -> Vec<(f64, f64, [u8; 4])> {
|
||||
let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 300.0, 300.0), 8.0, true);
|
||||
let mut out = vec![];
|
||||
for i in 0..50 {
|
||||
for j in 0..50 {
|
||||
let (u, v) = (0.03 + 0.94 * f64::from(i) / 49.0, 0.03 + 0.94 * f64::from(j) / 49.0);
|
||||
let p = arced(50.0, u, v);
|
||||
let px = img.pixel((p.x * 8.0).floor() as u32, (p.y * 8.0).floor() as u32);
|
||||
if !green.contains(&px[1]) && out.len() < 8 {
|
||||
out.push((u, v, px));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The pieces a warp cuts a half transparent image into overlap: the image stays half
|
||||
/// transparent there, on the canvas and in a PDF opened again.
|
||||
#[test]
|
||||
fn a_half_transparent_warped_image_has_no_darker_seams() {
|
||||
let mut s = session();
|
||||
half_clear_arc(&mut s);
|
||||
assert_eq!(not_half_red(&s, 120..=136), vec![], "canvas");
|
||||
// Opened again, each piece is drawn through masks of the pieces above it, and pixels on a
|
||||
// triangle's antialiased edge come out a little lighter. Pieces whose alphas add up where they
|
||||
// overlap draw a green of about 64 there.
|
||||
let pdf = s.execute("document.export", &json!({"format": "pdf", "preserveEditing": false})).unwrap();
|
||||
let mut back = session();
|
||||
back.execute("document.open", &json!({"name": "half.pdf", "dataBase64": pdf["dataBase64"]})).unwrap();
|
||||
assert_eq!(not_half_red(&back, 120..=152), vec![], "pdf");
|
||||
}
|
||||
|
||||
@@ -69,15 +69,25 @@ impl Exporter<'_> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// The PDF writer has no knockout groups: each element is drawn through a soft mask of
|
||||
// where the elements above it don't paint (the same look for Normal blending). The masks
|
||||
// nest, so element i sits inside the masks of elements i+1…n: open them outermost first.
|
||||
self.warn("knockout groups are written as soft-masked groups (same look, but not editable as knockout groups)");
|
||||
let elements: Vec<_> = Node::knockout_elements(children)
|
||||
.into_iter()
|
||||
.map(|c| (c, self.measure(c)))
|
||||
.filter(|(_, m)| m.bounds.is_some_and(|b| rects_overlap(b.inflate(1.0, 1.0), page)))
|
||||
.collect();
|
||||
if !elements.is_empty() && elements.iter().all(|(c, _)| c.knockout_outline().is_some()) {
|
||||
let pieces: Vec<&Node> = elements.iter().map(|(c, _)| &***c).collect();
|
||||
if vectorcraft_effects::warp_pieces_translucent(self.doc, &pieces) {
|
||||
return self.warp_pieces(s, &elements, page);
|
||||
}
|
||||
for (c, m) in &elements {
|
||||
self.measured_node(s, c, page, false, Some(m));
|
||||
}
|
||||
return;
|
||||
}
|
||||
// The PDF writer has no knockout groups: each element is drawn through a soft mask of
|
||||
// where the elements above it don't paint (the same look for Normal blending). The masks
|
||||
// nest, so element i sits inside the masks of elements i+1…n: open them outermost first.
|
||||
self.warn("knockout groups are written as soft-masked groups (same look, but not editable as knockout groups)");
|
||||
let Some(((first, measured), rest)) = elements.split_first() else { return };
|
||||
for (c, m) in rest.iter().rev() {
|
||||
let mask = self.knockout_mask(s, c, page, m);
|
||||
@@ -101,6 +111,40 @@ impl Exporter<'_> {
|
||||
s.opacity = 1.0;
|
||||
s
|
||||
};
|
||||
self.shapes_mask(s, &[(&shape, Some(measured))], page)
|
||||
}
|
||||
|
||||
/// The pieces of a partly transparent mesh-warped image (`elements`, each with its box) as the
|
||||
/// elements of a knockout group ([`vectorcraft_effects::warp_pieces_translucent`]). Each piece
|
||||
/// is drawn through one soft mask of where the pieces above it that reach its box don't cover
|
||||
/// ([`vectorcraft_effects::knockout_silhouette`]): only neighbors overlap, so each mask holds a
|
||||
/// few triangles and no mask nests in another.
|
||||
fn warp_pieces(&mut self, s: &mut Surface, elements: &[(&std::sync::Arc<Node>, Measured)], page: Rect) {
|
||||
// Each piece's triangle and its box: a piece paints inside its triangle only.
|
||||
let silhouette = |c: &Node| {
|
||||
let b = c.knockout_outline()?.geometric_bounds()?;
|
||||
Some((vectorcraft_effects::knockout_silhouette(c, vectorcraft_doc::color::Color::WHITE)?, b))
|
||||
};
|
||||
let silhouettes: Vec<Option<(Node, Rect)>> = elements.iter().map(|(c, _)| silhouette(c)).collect();
|
||||
for (i, (c, measured)) in elements.iter().enumerate() {
|
||||
let Some((_, reach)) = &silhouettes[i] else { continue };
|
||||
let reach = reach.inflate(1.0, 1.0);
|
||||
let shapes: Vec<(&Node, Option<&Measured>)> =
|
||||
silhouettes[i + 1..].iter().flatten().filter(|(_, b)| rects_overlap(*b, reach)).map(|(n, _)| (n, None)).collect();
|
||||
if shapes.is_empty() {
|
||||
self.measured_node(s, c, page, false, Some(measured));
|
||||
continue;
|
||||
}
|
||||
let mask = self.shapes_mask(s, &shapes, page);
|
||||
s.push_mask(mask);
|
||||
self.measured_node(s, c, page, false, Some(measured));
|
||||
s.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// A luminosity mask that is 1 − the coverage of `shapes` (each with its box when measured):
|
||||
/// white, then black through an alpha mask of the shapes drawn one over another.
|
||||
fn shapes_mask(&mut self, s: &mut Surface, shapes: &[(&Node, Option<&Measured>)], page: Rect) -> krilla::mask::Mask {
|
||||
let transform = s.group_transform();
|
||||
let mut sb = s.stream_builder();
|
||||
let mut ms = Surface::stream(sb.surface());
|
||||
@@ -112,7 +156,9 @@ impl Exporter<'_> {
|
||||
let mut als = Surface::stream(ab.surface());
|
||||
als.push_transform(&transform);
|
||||
let overprint = std::mem::take(&mut self.overprint);
|
||||
self.measured_node(&mut als, &shape, page, false, Some(measured));
|
||||
for (shape, measured) in shapes {
|
||||
self.measured_node(&mut als, shape, page, false, *measured);
|
||||
}
|
||||
self.overprint = overprint;
|
||||
als.pop();
|
||||
als.finish();
|
||||
|
||||
@@ -284,9 +284,20 @@ impl Renderer {
|
||||
ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, compose)), None, None, None);
|
||||
self.nested += 1;
|
||||
let shape = compose != Compose::Plus && !c.knockout_shape;
|
||||
self.shape_of = if shape { Arc::as_ptr(c) as usize } else { 0 };
|
||||
self.draw_arc(ctx, f, c);
|
||||
self.shape_of = 0;
|
||||
// A piece of a mesh-warped image knocks out its whole triangle, where its neighbors
|
||||
// overlap it, at full coverage whatever the image's own alpha there.
|
||||
if shape && let Some(outline) = c.knockout_outline() {
|
||||
if let Some(region) = self.clip_of(outline) {
|
||||
ctx.set_transform(f.view);
|
||||
ctx.set_paint(f.ink.fixed([255, 255, 255, 255]));
|
||||
ctx.set_fill_rule(fill_rule(region.1));
|
||||
ctx.fill_path(®ion.0);
|
||||
}
|
||||
} else {
|
||||
self.shape_of = if shape { Arc::as_ptr(c) as usize } else { 0 };
|
||||
self.draw_arc(ctx, f, c);
|
||||
self.shape_of = 0;
|
||||
}
|
||||
self.nested -= 1;
|
||||
ctx.pop_layer();
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ mod knockout;
|
||||
mod layeropts;
|
||||
mod longstroke;
|
||||
mod memory;
|
||||
mod meshwarp;
|
||||
mod movedstroke;
|
||||
mod objectfx;
|
||||
mod paintedbrush;
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! The pieces a raster mesh warp cuts an image into (envelopes, Effect › Warp) overlap their
|
||||
//! neighbors: a partly transparent image keeps its own alpha where they do.
|
||||
|
||||
use crate::*;
|
||||
use vectorcraft_doc::live::mesh_warp_image;
|
||||
use vectorcraft_doc::{ImageBlob, ImageObject, Node, NodeKind};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
/// A 4 × 4 px red PNG, every pixel `alpha` opaque.
|
||||
fn png(alpha: u8) -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, alpha]));
|
||||
let mut out = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// The image of [`png`] over (10, 10)–(90, 90), pushed through `f` as 4 × 4 cells (with `masked`,
|
||||
/// as Preserve Shape Using Transparency cuts it), on a page of 100 × 100 pt, after `tweak`
|
||||
/// adjusts the warped group.
|
||||
fn warped(f: &dyn Fn(Point) -> Point, masked: bool, pixelated: bool, tweak: impl FnOnce(&mut Node)) -> Document {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.images.insert("pic".into(), ImageBlob::png(png(128)));
|
||||
let im = ImageObject {
|
||||
key: "pic".into(),
|
||||
width: 4,
|
||||
height: 4,
|
||||
xf: Affine::translate((10.0, 10.0)) * Affine::scale(20.0),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im.clone())));
|
||||
let mut g = mesh_warp_image(&n, &im, f, 4, pixelated, masked);
|
||||
tweak(&mut g);
|
||||
d.insert(Some(d.layers[0].id), 0, g).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// `d` at 4 px a point over transparency.
|
||||
fn drawn(d: &Document) -> Rendered {
|
||||
Renderer::new().render(d, 400, 400, Affine::scale(4.0), &RenderOptions { background: None, ..Default::default() })
|
||||
}
|
||||
|
||||
/// The pixels of `r` inside (x0, y0)–(x1, y1) (points) whose alpha is more than 4 off `want`, with
|
||||
/// that alpha: the first few.
|
||||
fn uneven(r: &Rendered, (x0, y0, x1, y1): (u32, u32, u32, u32), want: u8) -> Vec<(u32, u32, u8)> {
|
||||
let mut out = vec![];
|
||||
for y in y0 * 4..y1 * 4 {
|
||||
for x in x0 * 4..x1 * 4 {
|
||||
let a = r.pixel(x, y)[3];
|
||||
if (i32::from(a) - i32::from(want)).abs() > 4 && out.len() < 8 {
|
||||
out.push((x, y, a));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Inside the image, away from its outer edges.
|
||||
const INSIDE: (u32, u32, u32, u32) = (12, 12, 88, 88);
|
||||
|
||||
#[test]
|
||||
fn a_half_transparent_image_is_half_transparent_where_its_warp_pieces_overlap() {
|
||||
let same = |p: Point| p;
|
||||
for masked in [false, true] {
|
||||
for pixelated in [false, true] {
|
||||
let r = drawn(&warped(&same, masked, pixelated, |_| {}));
|
||||
// The cells meet at x and y = 30, 50 and 70, and each cell's diagonal crosses it.
|
||||
assert_eq!(uneven(&r, INSIDE, 128), vec![], "masked {masked}, pixelated {pixelated}");
|
||||
}
|
||||
}
|
||||
// A wave bends every edge between the pieces.
|
||||
let wave = |p: Point| Point::new(p.x + 3.0 * (p.y / 7.0).sin(), p.y + 2.0 * (p.x / 9.0).cos());
|
||||
for masked in [false, true] {
|
||||
let r = drawn(&warped(&wave, masked, false, |_| {}));
|
||||
assert_eq!(uneven(&r, (16, 16, 84, 84), 128), vec![], "waved, masked {masked}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_images_opacity_applies_once_over_the_warp_pieces() {
|
||||
let same = |p: Point| p;
|
||||
for masked in [false, true] {
|
||||
let r = drawn(&warped(&same, masked, false, |g| g.opacity = 0.5));
|
||||
assert_eq!(uneven(&r, INSIDE, 64), vec![], "masked {masked}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A clip group of a half transparent image made by hand, in a knockout group, knocks out by
|
||||
/// where its image is opaque: only the pieces of a warp knock out by their whole triangle.
|
||||
#[test]
|
||||
fn other_clip_groups_in_a_knockout_group_knock_out_by_their_alpha() {
|
||||
let same = |p: Point| p;
|
||||
let d = warped(&same, false, false, |g| {
|
||||
let NodeKind::Group { children, .. } = &mut g.kind else { panic!("a group") };
|
||||
for c in children {
|
||||
Arc::make_mut(c).extras_mut().warp_piece = false;
|
||||
}
|
||||
});
|
||||
let r = drawn(&d);
|
||||
// On the shared edge at x = 30, y = 20: the neighbors' alphas add up there.
|
||||
let a = r.pixel(120, 80)[3];
|
||||
assert!(a > 160, "{a}");
|
||||
}
|
||||
@@ -122,14 +122,21 @@ impl Reuse {
|
||||
}
|
||||
|
||||
/// Are `children` the pieces of a raster mesh warp ([`vectorcraft_doc::live::mesh_warp_image`]):
|
||||
/// more than one, each a clip group of a triangle and one and the same image?
|
||||
/// more than one, each a clip group of a triangle and one and the same image, or that image masked
|
||||
/// by a triangle (Preserve Shape Using Transparency)?
|
||||
fn image_pieces(children: &[std::sync::Arc<Node>]) -> bool {
|
||||
let mut first: Option<(&str, u32, u32)> = None;
|
||||
children.len() > 1
|
||||
&& children.iter().all(|c| {
|
||||
let NodeKind::Group { children, clip: true } = &c.kind else { return false };
|
||||
let [clip, im] = children.as_slice() else { return false };
|
||||
let (NodeKind::Path { path, .. }, NodeKind::Image(im)) = (&clip.kind, &im.kind) else { return false };
|
||||
let (shape, im) = match (&c.kind, c.extras().mask.as_deref()) {
|
||||
(NodeKind::Group { children, clip: true }, _) => match children.as_slice() {
|
||||
[clip, im] => (&**clip, &**im),
|
||||
_ => return false,
|
||||
},
|
||||
(NodeKind::Image(_), Some(m)) => (&*m.art, &**c),
|
||||
_ => return false,
|
||||
};
|
||||
let (NodeKind::Path { path, .. }, NodeKind::Image(im)) = (&shape.kind, &im.kind) else { return false };
|
||||
let triangle = path.subpaths.len() == 1 && path.anchor_count() == 3;
|
||||
let image = (im.key.as_str(), im.width, im.height);
|
||||
triangle && *first.get_or_insert(image) == image
|
||||
@@ -578,6 +585,16 @@ impl Writer<'_> {
|
||||
return;
|
||||
}
|
||||
let elements = Node::knockout_elements(children);
|
||||
if !elements.is_empty() && elements.iter().all(|c| c.knockout_outline().is_some()) {
|
||||
let pieces: Vec<&Node> = elements.iter().map(|c| &***c).collect();
|
||||
if vectorcraft_effects::warp_pieces_translucent(self.doc, &pieces) {
|
||||
return self.warp_pieces(&elements);
|
||||
}
|
||||
for c in elements {
|
||||
self.node(c);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Where every element over another covers it fully, drawing them in order knocks out as
|
||||
// much: the masks would only add seams along the shapes' antialiased edges.
|
||||
if elements.iter().skip(1).all(|c| paints_opaque(c)) {
|
||||
@@ -616,6 +633,46 @@ impl Writer<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The pieces of a partly transparent mesh-warped image (`elements`) as the elements of a
|
||||
/// knockout group ([`vectorcraft_effects::warp_pieces_translucent`]). Each piece is drawn
|
||||
/// through one mask of where the pieces above it that reach it don't cover
|
||||
/// (their triangles, [`vectorcraft_effects::knockout_silhouette`], black over white):
|
||||
/// only neighbors overlap, so each mask holds a few triangles and no mask nests in another.
|
||||
fn warp_pieces(&mut self, elements: &[&std::sync::Arc<Node>]) {
|
||||
// Each piece's triangle and its box: a piece paints inside its triangle only.
|
||||
let silhouette = |c: &Node| {
|
||||
let b = c.knockout_outline()?.geometric_bounds()?;
|
||||
Some((vectorcraft_effects::knockout_silhouette(c, vectorcraft_doc::color::Color::BLACK)?, b))
|
||||
};
|
||||
let silhouettes: Vec<Option<(Node, Rect)>> = elements.iter().map(|c| silhouette(c)).collect();
|
||||
for (i, c) in elements.iter().enumerate() {
|
||||
let Some((own, reach)) = &silhouettes[i] else { continue };
|
||||
let reach = reach.inflate(1.0, 1.0);
|
||||
let overlaps = |b: &Rect| b.x0 <= reach.x1 && reach.x0 <= b.x1 && b.y0 <= reach.y1 && reach.y0 <= b.y1;
|
||||
let above: Vec<&Node> = silhouettes[i + 1..].iter().flatten().filter(|(_, b)| overlaps(b)).map(|(n, _)| n).collect();
|
||||
if above.is_empty() {
|
||||
self.node(c);
|
||||
continue;
|
||||
}
|
||||
let region = self.region_attrs(&[own]);
|
||||
let anonymous = std::mem::replace(&mut self.anonymous, true);
|
||||
let art: Vec<String> = above.iter().map(|a| self.detached(2, |w| w.node(a))).collect();
|
||||
self.anonymous = anonymous;
|
||||
let mid = self.fresh_id("knockout");
|
||||
self.def(1, &format!("<mask id=\"{mid}\" {MASK} {region}>"));
|
||||
self.def(2, &format!("<rect {region} fill=\"white\"/>"));
|
||||
for a in art {
|
||||
self.defs.push_str(&a);
|
||||
}
|
||||
self.def(1, "</mask>");
|
||||
self.line(&format!("<g mask=\"url(#{mid})\">"));
|
||||
self.depth += 1;
|
||||
self.node(c);
|
||||
self.depth -= 1;
|
||||
self.line("</g>");
|
||||
}
|
||||
}
|
||||
|
||||
/// A mask that is 1 − the knockout shape of `c`: white, then `c` painted black (at full object
|
||||
/// opacity without its own mask, unless those define its shape). `region` is the attributes of
|
||||
/// the mask's region ([`Self::region_attrs`]), which the filter that paints `c` black shares.
|
||||
|
||||
@@ -41,3 +41,4 @@ mod text_layout;
|
||||
mod text_paint_order;
|
||||
mod text_stroke;
|
||||
mod title_control_chars;
|
||||
mod warp_pieces;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
//! The pieces a raster mesh warp cuts an image into overlap their neighbors: in SVG a partly
|
||||
//! transparent image keeps its own alpha where they do, and the pieces share one copy of the image.
|
||||
|
||||
use vectorcraft_doc::live::mesh_warp_image;
|
||||
use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, Point};
|
||||
use vectorcraft_svg::{ExportOptions, export_full};
|
||||
|
||||
/// A 100 × 100 pt page holding a 4 × 4 px red image at half alpha over (10, 10)–(90, 90), pushed
|
||||
/// through `f` as 4 × 4 cells (with `masked`, as Preserve Shape Using Transparency cuts it).
|
||||
fn warped(f: &dyn Fn(Point) -> Point, masked: bool) -> Document {
|
||||
warped_pixels(f, masked, [255, 0, 0, 128], |_| {})
|
||||
}
|
||||
|
||||
/// [`warped`] with every pixel `rgba`, after `tweak` adjusts the image object.
|
||||
fn warped_pixels(f: &dyn Fn(Point) -> Point, masked: bool, rgba: [u8; 4], tweak: impl FnOnce(&mut Node)) -> Document {
|
||||
let img = image::RgbaImage::from_pixel(4, 4, image::Rgba(rgba));
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.images.insert("pic".into(), ImageBlob::png(png));
|
||||
let im = ImageObject {
|
||||
key: "pic".into(),
|
||||
width: 4,
|
||||
height: 4,
|
||||
xf: Affine::translate((10.0, 10.0)) * Affine::scale(20.0),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im.clone())));
|
||||
tweak(&mut n);
|
||||
let g = mesh_warp_image(&n, &im, f, 4, false, masked);
|
||||
d.insert(d.default_layer(), 0, g).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// `svg` drawn at 4 px a point over transparency: of the pixels inside (12, 12)–(88, 88), the most
|
||||
/// and the least opaque, each with its position.
|
||||
fn alpha_range(svg: &str) -> ((u32, u32, u8), (u32, u32, u8)) {
|
||||
let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()).unwrap();
|
||||
let mut pixmap = resvg::tiny_skia::Pixmap::new(400, 400).unwrap();
|
||||
resvg::render(&tree, resvg::tiny_skia::Transform::from_scale(4.0, 4.0), &mut pixmap.as_mut());
|
||||
let (mut most, mut least) = ((0, 0, 0u8), (0, 0, 255u8));
|
||||
for y in 48..352 {
|
||||
for x in 48..352 {
|
||||
let a = pixmap.pixel(x, y).unwrap().alpha();
|
||||
if a > most.2 {
|
||||
most = (x, y, a);
|
||||
}
|
||||
if a < least.2 {
|
||||
least = (x, y, a);
|
||||
}
|
||||
}
|
||||
}
|
||||
(most, least)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_half_transparent_image_is_half_transparent_where_its_warp_pieces_overlap() {
|
||||
let same = |p: Point| p;
|
||||
for masked in [false, true] {
|
||||
let svg = export_full(&warped(&same, masked), &ExportOptions::default(), None).svg;
|
||||
// Where pieces overlap, the image's alpha does not add up (it reached 75 % there). The
|
||||
// viewer rasterizes each piece's triangle and the masks over it one by one, so pixels on a
|
||||
// triangle's antialiased edge come out a little lighter.
|
||||
let (most, least) = alpha_range(&svg);
|
||||
assert!(most.2 <= 134, "masked {masked}: {most:?}");
|
||||
assert!(least.2 >= 104, "masked {masked}: {least:?}");
|
||||
// The 32 pieces draw one copy of the image.
|
||||
assert_eq!(svg.matches("data:image/png;base64,").count(), 1, "masked {masked}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Pieces of an opaque image are drawn one over another without knockout masks; a colorize paint
|
||||
/// draws a gray image's light pixels partly transparent, so its pieces take them.
|
||||
#[test]
|
||||
fn only_pieces_that_show_through_take_knockout_masks() {
|
||||
let same = |p: Point| p;
|
||||
let knockouts = |d: &Document| export_full(d, &ExportOptions::default(), None).svg.matches("id=\"knockout").count();
|
||||
assert_eq!(knockouts(&warped_pixels(&same, false, [128, 128, 128, 255], |_| {})), 0);
|
||||
let colorized = warped_pixels(&same, false, [128, 128, 128, 255], |n| {
|
||||
n.extras_mut().colorize = Some(Box::new(vectorcraft_doc::color::Paint::solid(vectorcraft_doc::color::Color::rgb(1.0, 0.0, 0.0))));
|
||||
});
|
||||
assert!(knockouts(&colorized) > 0);
|
||||
}
|
||||
@@ -1366,6 +1366,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
Window › Blend exposes easing Type, Ramp and independent color Shift through `object.blend.options`.
|
||||
Smooth Color counts the steps of each pair of keys from their color change, or from the distance between their visible bounding-box edges when their colors match or they hold gradients or patterns ([Blend Options](https://web.archive.org/web/20241217212108/https://helpx.adobe.com/illustrator/using/blending-objects.html)); the step counts, their caps and the colors compared are VectorCraft's own.
|
||||
Envelope Options › Anti-Alias off draws an envelope's distorted images without smoothing, and Preserve Shape Using › Transparency gives each piece of a distorted image an opacity mask in its shape instead of a clipping mask ([Envelope panel options](http://web.archive.org/web/20260513013456/https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/envelope-panel-options.html)).
|
||||
The pieces of an image that an envelope or a Warp effect distorts form a knockout group in which each piece knocks out by its whole triangle, so a partly transparent image keeps its own alpha where neighboring pieces overlap instead of drawing it twice there. SVG and PDF draw the pieces of a partly transparent image through masks of the triangles above them, and pixels on a triangle's antialiased edge come out slightly lighter there. Expanded envelopes keep the knockout group. Tests: `crates/render/src/tests/meshwarp.rs`, `crates/svg/tests/it/warp_pieces.rs`, `warp_images::a_half_transparent_warped_image_has_no_darker_seams` in `crates/engine`.
|
||||
The Mesh tool and Direct Selection drag the points and handles of an envelope made with Warp, as the help page describes for any envelope ([Distort objects with envelopes](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-objects-with-envelopes.html)). A selected warp shows the points of its surface on 4×4 patches (8×8 or 16×16 where 4×4 draws more than 0.1% of its diagonal away from the warp), and the first edit makes it a mesh envelope on that grid in the same undo step; Undo or Escape gives back the warp with its style and bend. The grid sizes, the 0.1% and the change to a mesh envelope are VectorCraft's own rules. Tests: `every_warp_style_has_a_grid_that_keeps_its_shape` in `crates/doc/src/live/envelope.rs`, `moving_a_warp_point_makes_it_a_mesh_envelope_in_one_step` and `an_edited_warp_saves_resets_releases_and_expands_as_edited` in `crates/engine/src/tests/envelope_edit.rs`, `the_mesh_tool_and_direct_selection_drag_warp_envelope_points` in `crates/tools/src/meshedit.rs`.
|
||||
With Envelope Options › Distort Appearance on, type's strokes (its characters' and those on its own appearance) paint along the bent glyph outlines at their own weight instead of bending with the art, as the drawn art of users' `.ai` files strokes type in an envelope. This is VectorCraft's own rule from those files: the public page names applied effects and graphic styles, not strokes. This holds in an envelope inside an envelope too. Paths' strokes, geometry effects (type's included), opacity masks (on type too), linear gradients and pattern fills bend as before; Expand gives type's bent outlines with their strokes on them. The visual bounds of envelopes, blends and repeats, and the reach of symbol art, count type's character strokes as type's own visual bounds do (half the weight of a centred stroke); they left them out, though those strokes already painted past an envelope's grid. Envelopes of stroked type made in VectorCraft before this draw their strokes at their own weight now (#6644; `crates/engine/src/tests/envelope_distort.rs`, `crates/effects/src/bake/tests.rs`).
|
||||
The four polynomial distributions are original approximations of the public description, not measured
|
||||
|
||||
Reference in New Issue
Block a user