From de5323efb63ada637dd6f86bbd10c201bdacb99c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 23:05:53 -0800 Subject: [PATCH] Expand Appearance: tinted symbols keep their colors under a color adjustment Expand Appearance on a symbol instance with a fill tint and a color adjustment applied the adjustment to the untinted art, so the expanded colors differed from the canvas. The expand path now passes the tinted instance art to adjust_in_document. --- crates/effects/src/adjust.rs | 8 +++++--- crates/effects/src/bake.rs | 2 +- crates/engine/src/cmd/effectcmd.rs | 9 ++++++++- crates/engine/src/tests/expand.rs | 23 +++++++++++++++++++++++ 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/crates/effects/src/adjust.rs b/crates/effects/src/adjust.rs index a656f11e85..c345136255 100644 --- a/crates/effects/src/adjust.rs +++ b/crates/effects/src/adjust.rs @@ -468,12 +468,14 @@ pub fn adjust(n: &Node, outer: Option<&ColorMap>, swatches: &SwatchKinds, images } /// [`adjust`] for export and Expand Appearance: recoloured embedded images become new images in -/// `d` (named by their content), and a symbol instance stands for its symbol's art. -pub fn adjust_in_document(d: &mut Document, n: &Node) -> Option { +/// `d` (named by their content), and a symbol instance stands for its symbol's art: `symbol_art` +/// when given, otherwise the symbol's art with the instance's own appearance changes. +pub fn adjust_in_document(d: &mut Document, n: &Node, symbol_art: Option) -> Option { let art; let src = match &n.kind { NodeKind::SymbolInstance { symbol, .. } if has_adjustment(n) => { - let symbol = d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n)); + let symbol = symbol_art + .or_else(|| d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n))); art = crate::reshape::as_art(Some(d), n, symbol.as_ref())?; &art } diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index afe256e596..ec0da9c46a 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -112,7 +112,7 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { } // Colour adjustments: the object recoloured (inside it too), then baked as it is. if has_adjustment(n) - && let Some(m) = adjust_in_document(d, n) + && let Some(m) = adjust_in_document(d, n, None) { return Some(bake_node(d, &m).unwrap_or(m)); } diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 8bf5c9a3d7..a19455bd51 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -814,8 +814,15 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br let mut changed = false; // Is `n` in `out` yet? let mut listed_n = false; + // A symbol instance's fill tints its art before the adjustment, as the canvas draws it. + let symbol_art = match &n.kind { + NodeKind::SymbolInstance { symbol, .. } if effects::has_adjustment(&n) => { + d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_render::instance_art(&s.art, &n)) + } + _ => None, + }; if effects::has_adjustment(&n) - && let Some(m) = effects::adjust_in_document(d, &n) + && let Some(m) = effects::adjust_in_document(d, &n, symbol_art) { out.push((id, 0)); listed_n = true; diff --git a/crates/engine/src/tests/expand.rs b/crates/engine/src/tests/expand.rs index 090cb98285..038b8844bb 100644 --- a/crates/engine/src/tests/expand.rs +++ b/crates/engine/src/tests/expand.rs @@ -75,6 +75,29 @@ fn symbol_instance_tint_survives_expanding_an_object_effect() { assert!(differing(&before, &after, 8) < 200, "expanding changed {} pixels", differing(&before, &after, 8)); } +/// The canvas tints a symbol's art with the instance fill before the instance's color adjustment, +/// and expanding gives the same colors. +#[test] +fn symbol_instance_tint_comes_before_a_color_adjustment_when_expanded() { + let mut s = session(); + square(&mut s); + let id = NodeId(run(&mut s, "symbol.new", json!({"name": "Tinted Square"}))["id"].as_u64().unwrap()); + run(&mut s, "appearance.addFill", json!({"ids": [id.0]})); + let fill = node(&s, id).appearance.items.len() - 1; + run(&mut s, "appearance.setItem", json!({"ids": [id.0], "index": fill, "color": "#00ff00", "opacity": 50})); + run(&mut s, "effect.apply", json!({"ids": [id.0], "item": null, "effect": "adjust.hueSaturation", "params": {"hue": 120}})); + let before = render(&s); + assert_ne!(before.pixel(100, 100), [255, 255, 255, 255], "the symbol is visible"); + assert!(expandable(&s)); + + run(&mut s, "effect.expandAppearance", json!({})); + assert!(!matches!(node(&s, id).kind, NodeKind::SymbolInstance { .. })); + let after = render(&s); + let (b, a) = (before.pixel(100, 100), after.pixel(100, 100)); + assert!(b.iter().zip(a.iter()).all(|(x, y)| x.abs_diff(*y) <= 8), "expanding changed the color from {b:?} to {a:?}"); + assert!(differing(&before, &after, 8) < 200, "expanding changed {} pixels", differing(&before, &after, 8)); +} + /// An instance fill with an opacity of 0 does not tint the symbol's art, so expanding keeps the /// art's global swatch link. #[test]