collective

Browse / github.com/storytold/vectorcraft / patch

Apply Transparency opacity to the clicked gradient mesh point

[Bug / Gradient Mesh] Node-level transparency incorrectly affects the entire object ### Bug Description When adjusting the opacity/transparency value of an individual mesh node within a Gradient Mesh object in VectorCraft, the transparency change is applied globally to the entire object instead of affecting only the selected node and its local blend radius. ### Expected Behavior Changing the opacity of a specific mesh node should only adjust the alpha channel for that node's local mesh area, creating a localized gradient fading effect. ### Environment - **App:** VectorCraft (Latest release) - **OS:** Linux (Debian Forky) - **Desktop Environment:** KDE Plasma 6.7 (Wayland)

Status
open
Score
0.00
Adopters
0 other operators
Author
Silvertree2010 (imported from pull request #4331, open)
Base
443b31b13df3a7ab611c2fe1b95e73898ed74fdd
Size
+84 −3 in 3 files
Review
pending
Shared
2026-10-28
Id
3796cdf9ff5e8ecccd97b2b7

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/opacitymask.rs crates/engine/src/cmd/paint.rs crates/engine/src/tests/editcolors.rs

diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs--- a/crates/engine/src/cmd/opacitymask.rs+++ b/crates/engine/src/cmd/opacitymask.rs@@ -343,6 +343,15 @@ impl TransparencyInfo {             ids,         }     }++    /// The opacity of the focused gradient mesh `point` (see `transparency.set`) instead of its+    /// object's.+    fn with_mesh_point(mut self, point: Option<(NodeId, usize, f32)>) -> Self {+        if let Some((_, _, o)) = point {+            self.opacity = Some((o as f64 * 10_000.0).round() / 100.0);+        }+        self+    } }  /// The nodes of `ids` that exist: looked up one by one when few, else in one walk of the tree.@@ -370,7 +379,10 @@ impl Session {     /// edited (`transparency.info` without ids).     pub fn transparency_info(&self) -> TransparencyInfo {         match (self.active(), transparency_targets(self, &Value::Null)) {-            (Some(st), Ok(ids)) => TransparencyInfo::of(&st.doc, ids),+            (Some(st), Ok(ids)) => {+                let point = super::paint::focused_mesh_point_of(self, &ids);+                TransparencyInfo::of(&st.doc, ids).with_mesh_point(point)+            }             _ => TransparencyInfo::default(),         }     }@@ -378,7 +390,9 @@ impl Session {  fn info(s: &mut Session, p: &Value) -> Result<Value> {     let st = s.doc()?;-    let i = TransparencyInfo::of(&st.doc, transparency_targets(s, p)?);+    let ids = transparency_targets(s, p)?;+    let point = super::paint::focused_mesh_point_of(s, &ids);+    let i = TransparencyInfo::of(&st.doc, ids).with_mesh_point(point);     Ok(json!({         "ids": i.ids.iter().map(|id| id.0).collect::<Vec<_>>(),         "opacity": i.opacity,diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs--- a/crates/engine/src/cmd/paint.rs+++ b/crates/engine/src/cmd/paint.rs@@ -68,7 +68,7 @@ pub fn specs() -> Vec<CommandSpec> {             "Transparency",             ["Window", "Transparency"],             None,-            "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects)",+            "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself)",             has_doc,             transparency         ),@@ -599,6 +599,14 @@ fn focused_mesh_point(s: &Session) -> Option<(NodeId, usize)> {     Some((NodeId(pt.get("id")?.as_u64()?), usize::try_from(pt.get("index")?.as_u64()?).ok()?)) } +/// The focused mesh point (see [`focused_mesh_point`]) with its opacity, when `ids` is just the+/// gradient mesh it belongs to.+pub(crate) fn focused_mesh_point_of(s: &Session, ids: &[NodeId]) -> Option<(NodeId, usize, f32)> {+    let (id, index) = focused_mesh_point(s).filter(|(id, _)| ids == [*id])?;+    let NodeKind::Mesh(m) = &s.doc().ok()?.doc.node(id)?.kind else { return None };+    Some((id, index, m.points.get(index)?.opacity))+}+ /// A type run's stroke; a painted stroke on a run without one gets 1 pt. fn set_run_stroke(style: &mut CharStyle, paint: Paint) {     if !paint.is_none() {@@ -742,6 +750,19 @@ fn transparency(s: &mut Session, p: &Value) -> Result<Value> {     q.remove("item");     q.remove("id");     q.insert("ids".into(), json!(ids.iter().map(|id| id.0).collect::<Vec<_>>()));+    // The gradient mesh point last clicked with the Mesh tool or Direct Selection takes the+    // opacity itself, so only the mesh around it fades (#4206).+    if matches!(item, ItemTarget::Top)+        && let Some(o) = q.get("opacity").and_then(Value::as_f64)+        && let Some((id, index, _)) = focused_mesh_point_of(s, &ids)+    {+        q.remove("opacity");+        let opacity = f64::from(super::opacitymask::percent(o));+        s.execute("object.mesh.setPointColor", &json!({"id": id.0, "index": index, "opacity": opacity}))?;+        if q.keys().all(|k| k == "ids") {+            return ok();+        }+    }     let ItemTarget::Item { index, explicit, aligned } = item else {         return s.execute("object.setProps", &Value::Object(q));     };diff --git a/crates/engine/src/tests/editcolors.rs b/crates/engine/src/tests/editcolors.rs--- a/crates/engine/src/tests/editcolors.rs+++ b/crates/engine/src/tests/editcolors.rs@@ -133,6 +133,52 @@ fn a_clicked_mesh_point_takes_the_fill_colour() {     assert_eq!((after[0], after[3]), (before[0], before[3])); } +fn mesh_opacities(s: &Session, id: NodeId) -> Vec<f32> {+    match &s.doc().unwrap().doc.node(id).unwrap().kind {+        NodeKind::Mesh(m) => m.points.iter().map(|p| p.opacity).collect(),+        _ => panic!("not a mesh"),+    }+}++/// The Transparency panel's opacity on a clicked mesh point fades that point alone, not the whole+/// object (#4206).+#[test]+fn a_clicked_mesh_point_takes_the_opacity() {+    let mut s = session();+    let m = rect(&mut s, json!("#ff0000"));+    s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap();+    s.execute("select.set", &json!({"ids": [m.0]})).unwrap();+    let v = ViewInfo::default();+    let click = |s: &mut Session, i: usize| {+        let NodeKind::Mesh(g) = &s.doc().unwrap().doc.node(m).unwrap().kind else { panic!("not a mesh") };+        let q = g.points[i].p;+        for kind in [PointerKind::Down, PointerKind::Up] {+            s.pointer(&PointerEvent::new(kind, q.x, q.y), v).unwrap();+        }+    };+    let object_opacity = |s: &Session| s.doc().unwrap().doc.node(m).unwrap().opacity;+    for (tool, point) in [("mesh", 4), ("directSelection", 4)] {+        s.select_tool(tool, v).unwrap();+        click(&mut s, point);+        let undo = s.doc().unwrap().history.undo.len();+        s.execute("transparency.set", &json!({"opacity": 30})).unwrap();+        let o = mesh_opacities(&s, m);+        assert!((o[4] - 0.3).abs() < 1e-6, "{tool}: the clicked point fades: {o:?}");+        assert!(o.iter().enumerate().all(|(i, a)| i == 4 || *a == 1.0), "{tool}: the other points stay opaque: {o:?}");+        assert_eq!(object_opacity(&s), 1.0, "{tool}: the object stays opaque");+        assert_eq!(s.transparency_info().opacity, Some(30.0), "{tool}: the panel shows the point's opacity");+        assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");+        s.execute("edit.undo", &json!({})).unwrap();+        assert!(mesh_opacities(&s, m).iter().all(|a| *a == 1.0));+    }+    // With no point focused the object takes the opacity.+    s.select_tool("selection", v).unwrap();+    s.execute("select.set", &json!({"ids": [m.0]})).unwrap();+    s.execute("transparency.set", &json!({"opacity": 30})).unwrap();+    assert!((object_opacity(&s) - 0.3).abs() < 1e-6);+    assert!(mesh_opacities(&s, m).iter().all(|a| *a == 1.0));+}+ #[test] fn blend_moves_a_mesh_to_the_blended_colour() {     let mut s = session();