Files
vectorcraft/crates/pdf/src/import_mask.rs
T
arhammusheer 5beac4f122 #1244: smaller objects: Node 352 -> 152 bytes, NodeKind 200 -> 80, and a cheaper undo estimate
Rarely set fields move into one optional boxed NodeExtras, and large NodeKind payloads (images,
live shapes, blends, envelopes) are boxed. Every object, and every undo step that copies it,
shrinks: a 50k-path move keeps ~24.5 MB per step instead of ~40 MB. Files stay byte-identical
(Node keeps its flat JSON through a borrowed Serialize and a from-wire Deserialize, no flatten),
and loading is slightly faster. Undo and redo reuse their step's size estimate instead of
walking the document again (8.3 -> 5.6 ms for a 50k-path move).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc

# Conflicts:
#	crates/engine/src/cmd/graph.rs
#	crates/svg/src/export.rs
2026-10-12 11:02:32 +07:00

134 lines
4.9 KiB
Rust

//! Soft masks → opacity masks. A luminosity mask's art becomes the mask art; an alpha mask's art
//! is turned white (its opacity kept), so its luminance over the black backdrop is its alpha.
//! The backdrop colour gives Clip (black: outside the art is hidden), an inverting transfer
//! function gives Invert, and the rectangles the PDF export draws for those (a backdrop cover, a
//! white Difference cover) read back as the options rather than as art. An alpha mask that is one
//! rectangle of constant opacity over everything is plain opacity.
use std::sync::Arc;
use kurbo::{Rect, Shape};
use vectorcraft_color::blend::MASK_LUM;
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{AppearanceItem, Node, NodeKind, OpacityMask};
/// What a soft mask does to what it masks.
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum MaskSpec {
/// A constant opacity (a group drawn through it is not isolated).
Opacity(f32),
Mask(OpacityMask),
}
/// The luminance of `c` (0 black … 1 white), as opacity masks weigh it.
pub(crate) fn luminance(c: &Color) -> f32 {
let rgb = c.to_rgb();
rgb.iter().zip(MASK_LUM).map(|(v, w)| v * w).sum()
}
/// A rectangle (at most 6 path elements, its area that of its bounds).
pub(crate) fn is_rectangle(p: &kurbo::BezPath) -> bool {
let b = p.bounding_box();
p.elements().len() <= 6 && b.area() > 0.0 && (p.area().abs() - b.area()).abs() <= b.area() * 1e-3
}
/// A path covering `page` filled with one solid colour and no stroke → its colour and opacity.
fn cover(n: &Node, page: Rect) -> Option<(Color, f32)> {
let NodeKind::Path { path, .. } = &n.kind else { return None };
let [AppearanceItem::Fill(f)] = n.appearance.items.as_slice() else { return None };
let Paint::Solid { color, .. } = &f.paint else { return None };
let b = path.to_bezpath();
let covers = page.area() > 1.0 && contains(b.bounding_box(), page.inset(-0.5)) && is_rectangle(&b);
(covers && n.extras().mask.is_none()).then_some((*color, f.opacity * n.opacity))
}
/// An opaque white rectangle covering `page`, filled only.
pub(crate) fn white_cover(n: &Node, page: Rect) -> bool {
cover(n, page).is_some_and(|(c, a)| a >= 0.999 && luminance(&c) > 0.999)
}
/// Does `outer` contain `inner`?
pub(crate) fn contains(outer: Rect, inner: Rect) -> bool {
outer.x0 <= inner.x0 && outer.y0 <= inner.y0 && outer.x1 >= inner.x1 && outer.y1 >= inner.y1
}
/// Every paint of `n` (and its children) white, opacities kept: an alpha mask's art as a
/// luminosity mask's.
fn whiten(n: &mut Node) {
let white = |p: &mut Paint| match p {
Paint::Solid { color, swatch, tint } => {
(*color, *swatch, *tint) = (Color::WHITE, None, 1.0);
}
Paint::Gradient(g) => {
for s in &mut g.gradient.stops {
(s.color, s.swatch, s.tint) = (Color::WHITE, None, 1.0);
}
}
Paint::None | Paint::Pattern { .. } => {}
};
for item in &mut n.appearance.items {
white(item.paint_mut());
}
match &mut n.kind {
NodeKind::Mesh(m) => m.points.iter_mut().for_each(|p| p.color = Color::WHITE),
NodeKind::Text(t) => t.runs.iter_mut().for_each(|r| {
white(&mut r.style.fill);
white(&mut r.style.stroke);
}),
_ => {}
}
if let Some(ch) = n.children_mut() {
for c in ch {
whiten(Arc::make_mut(c));
}
}
}
/// How a soft mask drawing `art` acts: `alpha` for an alpha mask, `backdrop` the luminance outside
/// the art, `inverted` when its transfer function inverts it. `page`: the area masks cover.
pub(crate) fn mask_spec(
mut art: Vec<Arc<Node>>,
alpha: bool,
backdrop: f32,
inverted: bool,
page: Rect,
group: impl FnOnce(Vec<Arc<Node>>) -> Node,
) -> MaskSpec {
if alpha {
if let [only] = art.as_slice()
&& let Some((_, a)) = cover(only, page)
{
return MaskSpec::Opacity(a);
}
art.iter_mut().for_each(|n| whiten(Arc::make_mut(n)));
}
let mut clip = alpha || backdrop < 0.5;
let mut invert = inverted;
// The PDF export's covers: a white Difference rectangle on top inverts, an opaque one at the
// bottom is the backdrop (white: no clip).
if !alpha {
if art.len() > 1
&& let Some(last) = art.last()
&& last.blend == BlendMode::Difference
&& white_cover(last, page)
{
art.pop();
invert = !invert;
}
if art.len() > 1
&& let Some(first) = art.first()
&& first.blend == BlendMode::Normal
&& let Some((c, a)) = cover(first, page)
&& a >= 0.999
{
clip = luminance(&c) < 0.5;
art.remove(0);
}
}
let art = match <[Arc<Node>; 1]>::try_from(art) {
Ok([one]) => Arc::unwrap_or_clone(one),
Err(all) => group(all),
};
MaskSpec::Mask(OpacityMask { invert, ..OpacityMask::new(art, clip) })
}