mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 05:24:44 +00:00
Envelopes: opaque pixels of a warped image stay opaque where its pieces overlap
In SVG and PDF output, each piece of a warped image whose pieces can show through was drawn through a mask of every later piece that reaches it. On a later piece's antialiased edge the viewer multiplies the earlier piece's coverage by the mask and draws the later piece over it, so an image with both opaque and partly transparent pixels came out lighter at its opaque pixels along the triangles' edges (alpha 184 of 255 in the new SVG test). A later piece now goes into an earlier piece's mask only when a pixel of either piece's image within 2 pixels of their overlap is less than opaque (vectorcraft_effects::WarpSeams). Where two pieces overlap over opaque pixels, the later one is drawn over the earlier one without a mask.
This commit is contained in:
@@ -272,6 +272,14 @@ impl ImageBlob {
|
||||
self.decoded().is_some_and(|img| img.pixels().any(|p| p.0[3] < 255))
|
||||
}
|
||||
|
||||
/// The alpha of each pixel, row by row from the top: the width, the height and one byte a
|
||||
/// pixel. `None` when the image can't be decoded. The last image decoded is kept, as for
|
||||
/// [`Self::sample`].
|
||||
pub fn alpha(&self) -> Option<(u32, u32, Vec<u8>)> {
|
||||
let img = self.decoded()?;
|
||||
Some((img.width(), img.height(), img.pixels().map(|p| p.0[3]).collect()))
|
||||
}
|
||||
|
||||
/// The decoded pixels (the last image decoded is kept for the next sample).
|
||||
fn decoded(&self) -> Option<Arc<image::RgbaImage>> {
|
||||
thread_local! {
|
||||
|
||||
@@ -60,6 +60,161 @@ pub fn warp_pieces_translucent(doc: &Document, pieces: &[&Node]) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Which overlaps of the pieces of a mesh-warped image ([`Node::warp_piece_image`]) can show
|
||||
/// through. Where an upper piece overlaps a lower one only over fully opaque pixels of the image,
|
||||
/// the SVG and PDF writers draw the upper piece over the lower one without a knockout mask for it:
|
||||
/// a mask there would make the opaque pixels on the upper piece's antialiased edge lighter.
|
||||
pub struct WarpSeams {
|
||||
pieces: Vec<Option<SeamPiece>>,
|
||||
alphas: Vec<(u32, u32, Vec<u8>)>,
|
||||
}
|
||||
|
||||
/// A piece of a mesh-warped image whose pixels decide whether its overlaps show through.
|
||||
struct SeamPiece {
|
||||
/// The piece's triangle, in document space.
|
||||
triangle: [vectorcraft_geom::Point; 3],
|
||||
/// Maps document space to the pixels of its image as decoded.
|
||||
to_pixels: vectorcraft_geom::Affine,
|
||||
/// The index of its image's alpha in [`WarpSeams::alphas`].
|
||||
alpha: usize,
|
||||
}
|
||||
|
||||
/// How far from an overlap, in image pixels, a less than opaque pixel makes the overlap show
|
||||
/// through: a smoothed image blends each pixel with its neighbors.
|
||||
const SEAM_MARGIN: f64 = 2.0;
|
||||
|
||||
impl WarpSeams {
|
||||
/// The overlaps of `pieces` (pieces of a mesh-warped image, in paint order), with the pixels of
|
||||
/// their images looked up in `doc`. Each distinct image is decoded once.
|
||||
pub fn new(doc: &Document, pieces: &[&Node]) -> Self {
|
||||
let mut images: Vec<*const vectorcraft_doc::ImageBlob> = vec![];
|
||||
let mut alphas = vec![];
|
||||
let pieces = pieces
|
||||
.iter()
|
||||
.map(|n| {
|
||||
let image = n.warp_piece_image()?;
|
||||
let NodeKind::Image(im) = &image.kind else { return None };
|
||||
let e = image.extras();
|
||||
if n.opacity < 1.0 || image.opacity < 1.0 || e.color_mask.is_some() || e.colorize.is_some() {
|
||||
return None;
|
||||
}
|
||||
let triangle = triangle(n.knockout_outline()?)?;
|
||||
let blob = doc.image_blob(im)?;
|
||||
let alpha = match images.iter().position(|b| std::ptr::eq(*b, blob)) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
alphas.push(blob.alpha()?);
|
||||
images.push(std::ptr::from_ref(blob));
|
||||
alphas.len() - 1
|
||||
}
|
||||
};
|
||||
let (w, h, _) = &alphas[alpha];
|
||||
if im.width == 0 || im.height == 0 || im.xf.determinant().abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let scale = vectorcraft_geom::Affine::scale_non_uniform(f64::from(*w) / f64::from(im.width), f64::from(*h) / f64::from(im.height));
|
||||
Some(SeamPiece { triangle, to_pixels: scale * im.xf.inverse(), alpha })
|
||||
})
|
||||
.collect();
|
||||
WarpSeams { pieces, alphas }
|
||||
}
|
||||
|
||||
/// Whether piece `above` (an index into the pieces) drawn over piece `below` can show the
|
||||
/// lower piece through where they overlap: either piece or its image is partly transparent, a
|
||||
/// color mask or a colorize paint changes its alpha, or a pixel of either image within
|
||||
/// [`SEAM_MARGIN`] of the overlap is less than opaque. `true` for a piece that is not a
|
||||
/// triangle of an image that can be decoded; `false` when the triangles do not overlap.
|
||||
pub fn shows_through(&self, below: usize, above: usize) -> bool {
|
||||
let (Some(Some(a)), Some(Some(b))) = (self.pieces.get(below), self.pieces.get(above)) else { return true };
|
||||
let overlap = convex_intersection(&a.triangle, &b.triangle);
|
||||
overlap.len() >= 3 && [a, b].into_iter().any(|p| self.translucent_near(p, &overlap))
|
||||
}
|
||||
|
||||
/// Whether a pixel of piece `p`'s image within [`SEAM_MARGIN`] of the convex polygon `overlap`
|
||||
/// (document space) is less than opaque.
|
||||
fn translucent_near(&self, p: &SeamPiece, overlap: &[vectorcraft_geom::Point]) -> bool {
|
||||
let Some((w, h, alpha)) = self.alphas.get(p.alpha) else { return true };
|
||||
let poly: Vec<vectorcraft_geom::Point> = overlap.iter().map(|q| p.to_pixels * *q).collect();
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
|
||||
for q in &poly {
|
||||
(x0, y0, x1, y1) = (x0.min(q.x), y0.min(q.y), x1.max(q.x), y1.max(q.y));
|
||||
}
|
||||
let span = |lo: f64, hi: f64, n: u32| {
|
||||
let lo = (lo - SEAM_MARGIN).floor().clamp(0.0, f64::from(n)) as u32;
|
||||
let hi = (hi + SEAM_MARGIN).ceil().clamp(0.0, f64::from(n)) as u32;
|
||||
lo..hi
|
||||
};
|
||||
let orientation = signed_area(&poly).signum();
|
||||
for y in span(y0, y1, *h) {
|
||||
for x in span(x0, x1, *w) {
|
||||
let c = vectorcraft_geom::Point::new(f64::from(x) + 0.5, f64::from(y) + 0.5);
|
||||
let near = poly.iter().zip(poly.iter().cycle().skip(1)).all(|(s, e)| {
|
||||
let edge = *e - *s;
|
||||
orientation * edge.cross(c - *s) >= -SEAM_MARGIN * edge.hypot()
|
||||
});
|
||||
let i = y as usize * *w as usize + x as usize;
|
||||
if near && alpha.get(i).is_none_or(|a| *a < 255) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// The three corners of the straight-sided triangle `outline` draws, in document space.
|
||||
fn triangle(outline: &Node) -> Option<[vectorcraft_geom::Point; 3]> {
|
||||
let (bp, _) = clip_outline(outline)?;
|
||||
let mut points = vec![];
|
||||
for el in bp.elements() {
|
||||
match el {
|
||||
vectorcraft_geom::PathEl::MoveTo(p) | vectorcraft_geom::PathEl::LineTo(p) if points.is_empty() || points.last() != Some(p) => {
|
||||
points.push(*p)
|
||||
}
|
||||
vectorcraft_geom::PathEl::MoveTo(_) | vectorcraft_geom::PathEl::LineTo(_) | vectorcraft_geom::PathEl::ClosePath => {}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
if points.len() == 4 && points.first() == points.last() {
|
||||
points.pop();
|
||||
}
|
||||
let [a, b, c] = points.as_slice() else { return None };
|
||||
Some([*a, *b, *c])
|
||||
}
|
||||
|
||||
/// Twice the signed area of polygon `poly`.
|
||||
fn signed_area(poly: &[vectorcraft_geom::Point]) -> f64 {
|
||||
poly.iter().zip(poly.iter().cycle().skip(1)).map(|(a, b)| a.to_vec2().cross(b.to_vec2())).sum()
|
||||
}
|
||||
|
||||
/// The intersection of convex polygons `subject` and `clip`, as a convex polygon (empty when they
|
||||
/// do not overlap).
|
||||
fn convex_intersection(subject: &[vectorcraft_geom::Point], clip: &[vectorcraft_geom::Point]) -> Vec<vectorcraft_geom::Point> {
|
||||
let orientation = signed_area(clip).signum();
|
||||
if orientation == 0.0 {
|
||||
return vec![];
|
||||
}
|
||||
let mut out = subject.to_vec();
|
||||
for (s, e) in clip.iter().zip(clip.iter().cycle().skip(1)) {
|
||||
let edge = *e - *s;
|
||||
let side = |p: vectorcraft_geom::Point| orientation * edge.cross(p - *s);
|
||||
let input = std::mem::take(&mut out);
|
||||
for (p, q) in input.iter().zip(input.iter().cycle().skip(1)) {
|
||||
let (sp, sq) = (side(*p), side(*q));
|
||||
if sp >= 0.0 {
|
||||
out.push(*p);
|
||||
}
|
||||
if (sp >= 0.0) != (sq >= 0.0) {
|
||||
out.push(*p + (*q - *p) * (sp / (sp - sq)));
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 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, knockout_silhouette, warp_pieces_translucent};
|
||||
pub use clip::{WarpSeams, 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,
|
||||
|
||||
@@ -295,7 +295,12 @@ fn a_warp_on_a_group_bends_the_image_in_it() {
|
||||
/// 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]));
|
||||
clear_arc(s, |_| 128);
|
||||
}
|
||||
|
||||
/// [`half_clear_arc`] with each column `x` of pixels at alpha `alpha(x)`.
|
||||
fn clear_arc(s: &mut Session, alpha: impl Fn(u32) -> u8) {
|
||||
let img = image::RgbaImage::from_fn(40, 40, |x, _| image::Rgba([255, 0, 0, alpha(x)]));
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
s.edit("Place", |d, sel| {
|
||||
@@ -346,3 +351,34 @@ fn a_half_transparent_warped_image_has_no_darker_seams() {
|
||||
back.execute("document.open", &json!({"name": "half.pdf", "dataBase64": pdf["dataBase64"]})).unwrap();
|
||||
assert_eq!(not_half_red(&back, 120..=152), vec![], "pdf");
|
||||
}
|
||||
|
||||
/// An image whose left half is opaque and whose right half is at half alpha keeps its opaque
|
||||
/// pixels opaque where the pieces of a warp overlap, away from the partly transparent pixels, on
|
||||
/// the canvas and in a PDF opened again.
|
||||
#[test]
|
||||
fn opaque_pixels_of_a_partly_transparent_warped_image_have_no_lighter_seams() {
|
||||
let mut s = session();
|
||||
clear_arc(&mut s, |x| if x < 20 { 255 } else { 128 });
|
||||
// The points of a fine grid over the left 30 % of the bent image, away from its outer edges,
|
||||
// where the document drawn on white at 8 px a point is not red: the first few.
|
||||
let not_red = |s: &Session| {
|
||||
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..300 {
|
||||
for j in 0..800 {
|
||||
let (u, v) = (0.03 + 0.27 * f64::from(i) / 299.0, 0.03 + 0.94 * f64::from(j) / 799.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 px[1] > 6 && out.len() < 8 {
|
||||
out.push((u, v, px));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
assert_eq!(not_red(&s), vec![], "canvas");
|
||||
let pdf = s.execute("document.export", &json!({"format": "pdf", "preserveEditing": false})).unwrap();
|
||||
let mut back = session();
|
||||
back.execute("document.open", &json!({"name": "mixed.pdf", "dataBase64": pdf["dataBase64"]})).unwrap();
|
||||
assert_eq!(not_red(&back), vec![], "pdf");
|
||||
}
|
||||
|
||||
@@ -118,7 +118,8 @@ impl Exporter<'_> {
|
||||
/// 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.
|
||||
/// few triangles and no mask nests in another. A piece above that overlaps it only over opaque
|
||||
/// pixels ([`vectorcraft_effects::WarpSeams`]) is left out of its mask.
|
||||
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| {
|
||||
@@ -126,11 +127,18 @@ impl Exporter<'_> {
|
||||
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();
|
||||
let pieces: Vec<&Node> = elements.iter().map(|(c, _)| &***c).collect();
|
||||
let seams = vectorcraft_effects::WarpSeams::new(self.doc, &pieces);
|
||||
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();
|
||||
let shapes: Vec<(&Node, Option<&Measured>)> = silhouettes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(i + 1)
|
||||
.filter_map(|(j, s)| s.as_ref().filter(|(_, b)| rects_overlap(*b, reach) && seams.shows_through(i, j)))
|
||||
.map(|(n, _)| (n, None))
|
||||
.collect();
|
||||
if shapes.is_empty() {
|
||||
self.measured_node(s, c, page, false, Some(measured));
|
||||
continue;
|
||||
|
||||
@@ -638,6 +638,8 @@ impl Writer<'_> {
|
||||
/// 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.
|
||||
/// A piece above that overlaps it only over opaque pixels ([`vectorcraft_effects::WarpSeams`])
|
||||
/// is left out of its mask.
|
||||
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| {
|
||||
@@ -645,11 +647,19 @@ impl Writer<'_> {
|
||||
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();
|
||||
let pieces: Vec<&Node> = elements.iter().map(|c| &***c).collect();
|
||||
let seams = vectorcraft_effects::WarpSeams::new(self.doc, &pieces);
|
||||
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();
|
||||
let above: Vec<&Node> = silhouettes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(i + 1)
|
||||
.filter_map(|(j, s)| s.as_ref().filter(|(_, b)| overlaps(b) && seams.shows_through(i, j)))
|
||||
.map(|(n, _)| n)
|
||||
.collect();
|
||||
if above.is_empty() {
|
||||
self.node(c);
|
||||
continue;
|
||||
|
||||
@@ -72,6 +72,52 @@ fn a_half_transparent_image_is_half_transparent_where_its_warp_pieces_overlap()
|
||||
}
|
||||
}
|
||||
|
||||
/// An image whose left half is opaque and whose right half is at half alpha, cut into 4 × 4 cells:
|
||||
/// its opaque pixels stay opaque on the pieces' antialiased edges, away from the partly
|
||||
/// transparent pixels, and the right half stays half transparent.
|
||||
#[test]
|
||||
fn opaque_pixels_stay_opaque_where_warp_pieces_overlap() {
|
||||
let img = image::RgbaImage::from_fn(16, 16, |x, _| image::Rgba([255, 0, 0, if x < 8 { 255 } else { 128 }]));
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
for masked in [false, true] {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.images.insert("pic".into(), ImageBlob::png(png.clone()));
|
||||
let im = ImageObject {
|
||||
key: "pic".into(),
|
||||
width: 16,
|
||||
height: 16,
|
||||
xf: Affine::translate((10.0, 10.0)) * Affine::scale(5.0),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im.clone())));
|
||||
let g = mesh_warp_image(&n, &im, &|p| p, 4, false, masked);
|
||||
d.insert(d.default_layer(), 0, g).unwrap();
|
||||
let svg = export_full(&d, &ExportOptions::default(), None).svg;
|
||||
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 alpha = |x0: u32, x1: u32| {
|
||||
let mut range = (255u8, 0u8);
|
||||
for y in 48..352 {
|
||||
for x in x0..x1 {
|
||||
let a = pixmap.pixel(x, y).unwrap().alpha();
|
||||
range = (range.0.min(a), range.1.max(a));
|
||||
}
|
||||
}
|
||||
range
|
||||
};
|
||||
// The first column of cells and its seam with the second, (12, 12)–(29.75, 88).
|
||||
let opaque = alpha(48, 119);
|
||||
assert!(opaque.0 >= 253, "masked {masked}: {opaque:?}");
|
||||
// The right half, (62, 12)–(88, 88).
|
||||
let half = alpha(248, 352);
|
||||
assert!(half.0 >= 104 && half.1 <= 134, "masked {masked}: {half:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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]
|
||||
|
||||
Reference in New Issue
Block a user