From a327587d645f87b73cc82c4d2d21fef0989b230a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 20:12:25 -0800 Subject: [PATCH] Effects: nested groups' Transform effects round a member's direction once A member's light or stroke direction under groups that each have a Transform effect is now rounded once for the turn of all of them. The inner group's members are evaluated first, so each group rounded the direction again: two 30-degree turns of a top light gave Left instead of Top Left. While a group's members are evaluated, the group's own Transform effects are counted among those the members still go through. --- crates/effects/src/group.rs | 6 +++- crates/effects/src/raster.rs | 37 ++++++++++++++++++++++-- crates/effects/src/tests/rasterfollow.rs | 23 +++++++++++++++ 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 1b64971a3a..8d71b7f1a1 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -868,7 +868,11 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { } if !fx.is_empty() { m = crate::reshape::outlined_members(doc, &m); - members_as_drawn(doc, &mut m); + { + // The members' raster effects then follow the group's own Transform effects as well. + let _later = crate::raster::LaterGroups::enter(&n.appearance.effects); + members_as_drawn(doc, &mut m); + } let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, &n.appearance.effects, doc); // The group's own fills and strokes paint the members as a whole: their gradients follow // the effects as the members' do. diff --git a/crates/effects/src/raster.rs b/crates/effects/src/raster.rs index 9dcff61f0d..7f57abe4bf 100644 --- a/crates/effects/src/raster.rs +++ b/crates/effects/src/raster.rs @@ -613,12 +613,43 @@ pub(crate) fn follows(id: &str) -> bool { crate::is_raster(id) && id != crate::SVG_FILTER && id != RASTERIZE } +thread_local! { + /// The linear part of the Transform effects of the groups and layers whose members are being + /// evaluated ([`LaterGroups`]), which those groups apply to the members' raster effects once + /// the members are evaluated: the members' light and stroke directions are rounded for them as + /// well ([`follow_transform`]). + static LATER_GROUPS: std::cell::Cell = const { std::cell::Cell::new(Affine::IDENTITY) }; +} + +/// The Transform effects of a group or layer whose members are being evaluated before the group's +/// own effects are applied to them, added to [`LATER_GROUPS`] until it is dropped. +pub(crate) struct LaterGroups(Affine); + +impl LaterGroups { + /// Add the Transform effects of `owner`, a group's or layer's own effects ([`contents_step`]), + /// to those the members will still go through. + pub(crate) fn enter(owner: &[Effect]) -> Self { + let was = LATER_GROUPS.with(|l| l.get()); + if let Some(step) = contents_step(owner) { + LATER_GROUPS.with(|l| l.set(was * step)); + } + LaterGroups(was) + } +} + +impl Drop for LaterGroups { + fn drop(&mut self) { + LATER_GROUPS.with(|l| l.set(self.0)); + } +} + /// Make raster effect `e` follow `a`, the linear part of a Transform effect listed after it, as /// that effect transforms what `e` paints: its distances and raster sizes scale by `a`'s mean /// scale ([`crate::scale_effect`]), a Drop Shadow's offset turns, reflects and scales with `a`, /// and a pixel filter's light direction, stroke direction or screen angles turn and reflect with -/// it ([`pixel::turned_directions`]; `later` is the map `e` still goes through when it is drawn). -/// Effects that [`follows`] leaves out stay as they are. +/// it ([`pixel::turned_directions`]; `later` is the map `e` still goes through when it is drawn, +/// before the Transform effects of the groups around it in [`LATER_GROUPS`]). Effects that +/// [`follows`] leaves out stay as they are. fn follow_transform(e: &mut Effect, a: Affine, later: Affine) { if !follows(&e.id) { return; @@ -627,7 +658,7 @@ fn follow_transform(e: &mut Effect, a: Affine, later: Affine) { let p = merged_params(&e.id, &e.params); a * Point::new(num(&p, "x", 7.0), num(&p, "y", 7.0)) }); - let turned = pixel::turned_directions(&e.id, &e.params, a, later); + let turned = pixel::turned_directions(&e.id, &e.params, a, LATER_GROUPS.with(|l| l.get()) * later); if let Some(k) = vectorcraft_doc::Scaling::factor(a) { crate::scale_effect(e, k); } diff --git a/crates/effects/src/tests/rasterfollow.rs b/crates/effects/src/tests/rasterfollow.rs index ae407fd438..3d3d81c172 100644 --- a/crates/effects/src/tests/rasterfollow.rs +++ b/crates/effects/src/tests/rasterfollow.rs @@ -580,6 +580,29 @@ fn nested_and_baked_directions_match_canvas() { } } +/// A member's light under two nested groups that each turn 30° turns 60° in all, rounded once: +/// Top Left, not Left (Top Left after the inner group's turn, then Left after the outer one's). +#[test] +fn nested_group_turns_round_the_light_once() { + let relief = || fx("sketch.basRelief", json!({"lightDirection": "top"})); + let thirty = || transform(json!({"rotate": 30})); + let inner = group(41, vec![square(42, vec![relief()]), fill_square(43, vec![relief()])], vec![thirty()]); + let outer = group(40, vec![inner], vec![thirty()]); + let art = crate::evaluate_container(None, &outer).unwrap(); + assert_eq!(lights_in(&art), vec![Light::TopLeft; 2]); + let mut d = Document::new(800.0, 800.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, outer).unwrap(); + let baked = bake_document(&d).unwrap(); + for id in [42, 43] { + assert_eq!(lights_in(baked.node(NodeId(id)).unwrap()), vec![Light::TopLeft], "{id}"); + } + // Three groups make 90°: Left, not Bottom Left. + let three = group(44, vec![group(45, vec![group(46, vec![square(47, vec![relief()])], vec![thirty()])], vec![thirty()])], vec![thirty()]); + let art = crate::evaluate_container(None, &three).unwrap(); + assert_eq!(lights_in(&art), vec![Light::Left]); +} + /// A Transform effect after a 3D effect that makes copies or moves at random holds back the shadow /// before the 3D effect, as it does without the 3D effect. #[test]