Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 fdb7d7a183 Part of #812: Smart Guides label an object's centre and anchors on hover
With Smart Guides on, hovering the Selection tool near an object's centre
or one of its anchors now labels it ("center", "anchor"), as
Illustrator does, and a press there grabs the object by that point, so
the point follows the pointer and snaps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:28:38 +07:00
bflatasticandClaude Opus 5.5 99671cabd8 Fix #827: shadows and glows keep their blend modes in PDF
A raster effect painted below its object (Drop Shadow, Outer Glow) is
written as an image under the object's vector art. The image was
rendered over nothing and placed with Normal blending, so a Screen glow
or a Multiply shadow came out as Normal in the PDF. Each image is now
composited with its effects' blend mode, one image per mode when an
object's shadows and glows use several.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:28:04 +07:00
4 changed files with 158 additions and 7 deletions

No files matched your search

+7
View File
@@ -52,6 +52,13 @@ impl RasterFx {
pub fn is_below(&self) -> bool {
matches!(self, RasterFx::DropShadow { .. } | RasterFx::OuterGlow { .. })
}
/// The blend mode it paints with (shadows and glows).
pub fn mode(&self) -> Option<BlendMode> {
match self {
RasterFx::DropShadow { mode, .. } | RasterFx::OuterGlow { mode, .. } | RasterFx::InnerGlow { mode, .. } => Some(*mode),
_ => None,
}
}
/// How far this effect reaches beyond `bounds`, what it paints around (Radial Blur reaches
/// further around larger objects).
pub fn outset(&self, bounds: Rect) -> f64 {
+46 -6
View File
@@ -15,7 +15,7 @@
use std::sync::Arc;
use serde_json::{Map, Value, json};
use vectorcraft_color::Paint;
use vectorcraft_color::{BlendMode, Paint};
use vectorcraft_doc::rastersettings::MAX_ADD_AROUND;
use vectorcraft_doc::{
Appearance, AppearanceItem, ColorMode, Document, Effect, ImageBlob, ImageObject, Node, NodeId, NodeKind, RasterColorModel, StrokeLayer,
@@ -217,18 +217,55 @@ pub(crate) fn raster_fx(n: &Node) -> Vec<RasterFx> {
/// Remove the raster effects of `n` (its own and its fills' and strokes') and, with `members`, of
/// everything inside it.
pub(crate) fn strip_raster(n: &mut Node, members: bool) {
let keep = |e: &Effect| !effects::is_raster(&e.id);
retain_effects(n, members, &|e| !effects::is_raster(&e.id));
}
/// Keep the effects of `n` (its own and its fills' and strokes') that `keep` accepts and, with
/// `members`, of everything inside it.
fn retain_effects(n: &mut Node, members: bool, keep: &dyn Fn(&Effect) -> bool) {
n.appearance.effects.retain(keep);
for it in &mut n.appearance.items {
it.effects_mut().retain(keep);
}
if members {
for c in n.children_mut().into_iter().flatten() {
strip_raster(Arc::make_mut(c), true);
retain_effects(Arc::make_mut(c), true, keep);
}
}
}
/// The blend mode raster effect `e` paints with (`None`: not a raster effect, or one without).
fn effect_mode(e: &Effect) -> Option<BlendMode> {
effects::raster_effects(std::slice::from_ref(e)).first().and_then(RasterFx::mode)
}
/// The images of the shadows and outer glows of `n`, one per blend mode they use (in the order
/// the modes first come), each composited with its mode: rendered alone, over nothing, they can't
/// blend with the art below the object, so the image does.
fn below_images(d: &mut Document, n: &Node) -> Option<Vec<Node>> {
let mut modes: Vec<BlendMode> = vec![];
for mode in raster_fx(n).iter().filter_map(RasterFx::mode) {
if !modes.contains(&mode) {
modes.push(mode);
}
}
if modes.len() < 2 {
let mut image = raster_image(d, n, true, false)?;
image.blend = modes.first().copied().unwrap_or_default();
return Some(vec![image]);
}
modes
.into_iter()
.map(|mode| {
let mut only = n.clone();
retain_effects(&mut only, false, &|e| effect_mode(e).is_none_or(|m| m == mode));
let mut image = raster_image(d, &only, true, false)?;
image.blend = mode;
Some(image)
})
.collect()
}
/// The raster effects of `n` (its own, its fills' and strokes') as an embedded image of `d`
/// rendered at the document's raster effects resolution, without `n`'s transparency (that stays
/// on the object). With `below` (they all paint below it: shadows and outer glows) the image holds
@@ -405,15 +442,18 @@ fn walk(out: &mut Document, n: &Node, which: Which) -> Option<Node> {
return Some(walk(out, &g, which).unwrap_or(g));
}
// An image that can't be made leaves the object as it is (the writer reports its effects).
let image = raster_image(out, n, below, false)?;
let mut v = n.clone();
strip_raster(&mut v, false);
let mut m = if below {
let images = below_images(out, n)?;
let mut m = walk(out, &v, which).unwrap_or(v);
put_below(out, &mut m, image);
// Each goes in first: the last one put is the lowest.
for image in images.into_iter().rev() {
put_below(out, &mut m, image);
}
m
} else {
replace_with_image(v, image)
replace_with_image(v, raster_image(out, n, false, false)?)
};
walk_mask(out, &mut m, which);
Some(m)
+32
View File
@@ -150,3 +150,35 @@ fn live_objects_and_single_fills_and_strokes_keep_their_raster_effects() {
assert_eq!(pieces, 2, "item {item}: one piece per fill and stroke");
}
}
#[test]
fn shadows_and_glows_keep_their_blend_modes() {
// #827: a red Screen glow over grey can't darken it, as Normal would.
let glow = Effect {
id: "stylize.outerGlow".into(),
params: json!({"mode": "screen", "color": "#ff0000", "opacity": 100.0, "blur": 20.0}),
visible: true,
};
let mut d = Document::new(200.0, 200.0);
let back = rect(&mut d, Rect::new(0.0, 0.0, 200.0, 200.0), Color::rgb(0.5, 0.5, 0.5));
add(&mut d, back);
let dot = rect(&mut d, Rect::new(70.0, 70.0, 130.0, 130.0), Color::rgb(0.12, 0.12, 0.12));
add(&mut d, with(dot, glow.clone()));
assert_written("screen glow", &d);
let r = crate::cmd::rasterfx::export_pdf_with_report(&d, &Default::default()).unwrap();
let back = render(&vectorcraft_pdf::import(&r.bytes).unwrap());
for x in [133, 138, 145] {
let i = (100 * back.width as usize + x) * 4;
let (red, green, blue) = (back.pixels[i], back.pixels[i + 1], back.pixels[i + 2]);
assert!(red > green && green >= 124 && blue >= 124, "x {x}: {:?}", (red, green, blue));
}
// A Multiply shadow and the Screen glow: an image each, composited with its mode.
let mut d = Document::new(200.0, 200.0);
let dot = rect(&mut d, Rect::new(70.0, 70.0, 130.0, 130.0), Color::rgb(0.12, 0.12, 0.12));
add(&mut d, with(with(dot, shadow()), glow));
let flat = crate::flatten_raster_effects(&d).unwrap();
let modes: Vec<_> =
flat.layers[0].children().unwrap()[0].children().unwrap().iter().filter(|c| matches!(c.kind, NodeKind::Image(_))).map(|c| c.blend).collect();
assert_eq!(modes, [vectorcraft_color::BlendMode::Multiply, vectorcraft_color::BlendMode::Screen]);
}
+73 -1
View File
@@ -71,6 +71,8 @@ pub struct SelectionTool {
targets: Option<Targets>,
moving: Option<MoveSnap>,
guide: GuideEdit,
/// Smart Guides: the centre or anchor under the pointer, and its label ([`Self::hover_point`]).
hover: Option<(Point, &'static str)>,
}
/// Snapping for the selection moved as a whole, its targets gathered when the move begins: View →
@@ -163,10 +165,36 @@ fn box_hit(cx: &ToolContext, b: &OrientedBox, p: Point) -> Option<BoxHit> {
in_rotate_zone(b.rect, lp, tol, cx.tol(18.0)).map(|_| BoxHit::Rotate)
}
/// How near (screen points) the pointer must come to an object's centre or anchor for Smart Guides
/// to label it while hovering.
const HOVER_REACH: f64 = 6.0;
impl SelectionTool {
fn drag_threshold(cx: &ToolContext) -> f64 {
cx.tol(3.0)
}
/// With Smart Guides on, the anchor or the centre of an object near `p` (the one under the
/// pointer, else a selected one) and its label, "anchor" or "center", as Illustrator shows
/// them while hovering (#812). A press there grabs the object by that point.
fn hover_point(cx: &ToolContext, p: Point) -> Option<(Point, &'static str)> {
if !cx.smart_guides {
return None;
}
let reach = cx.tol(HOVER_REACH);
let under = hit_test(cx.doc, p, cx.hit_options()).map(|h| h.top_object(cx.isolation));
let nearest = |points: &mut dyn Iterator<Item = Point>| {
points.filter(|q| q.distance(p) <= reach).min_by(|a, b| a.distance(p).total_cmp(&b.distance(p)))
};
under.into_iter().chain(cx.selection.objects.iter().copied()).find_map(|id| {
let n = cx.doc.node(id)?;
let anchor = n.path_data().and_then(|pd| nearest(&mut pd.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p))));
anchor.map(|q| (q, "anchor")).or_else(|| {
let c = cx.doc.bounds_of(&[id], cx.preview_bounds)?.center();
(c.distance(p) <= reach).then_some((c, "center"))
})
})
}
}
impl Tool for SelectionTool {
@@ -188,6 +216,11 @@ impl Tool for SelectionTool {
}
let p = ev.pos;
let m = ev.mods;
// A press near the hovered centre or anchor grabs the object by it.
let grab = self.hover.take().map(|(q, _)| q).filter(|q| q.distance(p) <= cx.tol(HOVER_REACH)).unwrap_or(p);
if matches!(ev.kind, PointerKind::Move) && matches!(self.state, State::Idle) {
self.hover = Self::hover_point(cx, p);
}
match (ev.kind, self.state.clone()) {
(PointerKind::DoubleClick, _) => {
self.state = State::Idle;
@@ -278,7 +311,7 @@ impl Tool for SelectionTool {
} else if cx.selection.objects.len() > 1 && !m.cmd && !m.alt {
key = Some(top);
}
self.state = State::Moving { start: p, began: false, deselect, key };
self.state = State::Moving { start: grab, began: false, deselect, key };
out
}
None => {
@@ -397,6 +430,12 @@ impl Tool for SelectionTool {
}
o.extend(self.guides.iter().cloned());
o.extend(self.guide.overlays(cx));
if let (Some((q, label)), State::Idle) = (self.hover, &self.state) {
let style = crate::guides::GuideStyle::of(cx);
if style.labels {
o.push(Overlay::Label { p: q, text: label.into(), color: style.color });
}
}
if let Some((p, t)) = &self.measure {
o.push(Overlay::Measure { p: *p, text: t.clone() });
}
@@ -483,6 +522,39 @@ mod tests {
PointerEvent::new(kind, x, y)
}
/// #812: with Smart Guides on, hovering near an object's centre or an anchor labels it, and a
/// press there grabs the object by that point.
#[test]
fn hovering_labels_the_centre_and_anchors_and_a_press_grabs_by_them() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let label = |t: &SelectionTool| {
t.overlays(&cx).iter().find_map(|o| match o {
Overlay::Label { p, text, .. } => Some((*p, text.clone())),
_ => None,
})
};
let mut t = SelectionTool::default();
t.pointer(&cx, &ev(PointerKind::Move, 152.0, 151.0));
assert_eq!(label(&t), Some((Point::new(150.0, 150.0), "center".to_string())));
t.pointer(&cx, &ev(PointerKind::Move, 102.0, 101.0));
assert_eq!(label(&t), Some((Point::new(100.0, 100.0), "anchor".to_string())));
t.pointer(&cx, &ev(PointerKind::Move, 130.0, 170.0));
assert_eq!(label(&t), None, "away from both");
// Grabbed by its centre, the object's centre follows the pointer.
t.pointer(&cx, &ev(PointerKind::Move, 152.0, 151.0));
t.pointer(&cx, &ev(PointerKind::Down, 152.0, 151.0));
let a = t.pointer(&cx, &ev(PointerKind::Drag, 250.0, 250.0));
assert!(a.iter().any(|a| matches!(a, Action::Preview(_, v) if v["matrix"][4] == 100.0 && v["matrix"][5] == 100.0)), "{a:?}");
// With Smart Guides off, nothing is labelled.
let off = ToolContext { smart_guides: false, ..cx };
let mut t = SelectionTool::default();
t.pointer(&off, &ev(PointerKind::Move, 152.0, 151.0));
assert!(!t.overlays(&off).iter().any(|o| matches!(o, Overlay::Label { .. })));
}
#[test]
fn click_selects_object() {
let (d, id) = doc_with_rect();