diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 2b4953d151..b77f804de7 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -424,6 +424,8 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option { if !kept { return None; } + // The members' own effects run first, as on the canvas. + crate::group::members_as_drawn(Some(d), &mut m); reshape_leaves(&mut m, &fx, &n.appearance.effects, Some(d)); m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects); m.appearance.effects.retain(|e| !is_pathfinder(&e.id)); diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 959bd5e857..a6581fa449 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -209,6 +209,8 @@ fn reshaped_art(doc: Option<&Document>, n: &Node, hook: Option, nod Some(area) => Some(reshaped(&area, n, doc)), None => { let mut m = crate::reshape::outlined_members_with(None, doc, n, outline, nodes); + // The members' own effects run first, as on the canvas. + 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 follow the effects as its members do. if carry.moves(&m.appearance) { @@ -225,6 +227,7 @@ fn reshaped_art(doc: Option<&Document>, n: &Node, hook: Option, nod let art = h(n).filter(|art| nodes.charge(art))?; let mut art = crate::reshape::symbol_art_with(&art, h, nodes, symbol); crate::reshape::outline_nested_text(doc, &mut art, false); + members_as_drawn(doc, &mut art); let carry = crate::reshape::reshape_leaves_carrying(&mut art, &fx, &n.appearance.effects, doc); // The art's own fills and strokes follow the effects as its members do. if carry.moves(&art.appearance) { @@ -754,7 +757,8 @@ fn take_sublayer_guides(n: &mut Node, out: &mut Vec>, hidden: bool, lo } /// `n`, a group or layer, as plain art: its Pathfinder effects evaluated, its geometry effects -/// reshaping all its members as one piece (the whole art's bounds as their reference box), and its +/// reshaping all its members as one piece (the whole art's bounds as their reference box) after +/// the members' own geometry and Pathfinder effects ([`members_as_drawn`]), and its /// own fills and strokes turned into art painting the members' geometry ([`member_shapes`]) below /// the members (after a clipping path) or above them, as its contents slot says. Its /// transparency, opacity mask and raster effects stay. `None` when it has none of these. Live @@ -790,6 +794,7 @@ 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); 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. @@ -821,5 +826,83 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { Some(m) } +/// Replace each visible member of `n` with geometry or Pathfinder effects of its own, at every +/// depth of its groups and layers, by the art it draws ([`evaluate_container`], +/// [`crate::reshape::own_effects_art`]), so the geometry effects of `n` reshape what its members +/// draw. A clip group's clipping path stays as it is, and so do images, symbol instances and the +/// other objects that `own_effects_art` leaves alone, and a group or layer with a 3D effect with +/// everything in it. Calls nested in one another, through the +/// groups and layers it walks and the art it evaluates (symbol art and Pathfinder members +/// included), stop at [`vectorcraft_doc::depth::MAX_DEPTH`] deep, and together they replace at +/// most [`crate::SYMBOL_ART_MAX_NODES`] members: past either limit, members stay as they are. +pub(crate) fn members_as_drawn(doc: Option<&Document>, n: &mut Node) { + let Some(_frame) = DrawnFrame::enter() else { return }; + let clip = usize::from(n.clips()); + let Some(ch) = n.children_mut() else { return }; + for c in ch.iter_mut().skip(clip).filter(|c| c.visible) { + if !DrawnFrame::take_member() { + return; + } + // A group or layer with a 3D effect stays whole: the geometry effects listed after its 3D + // effect reshape its 3D art. + if is_container(c) && (crate::has_solid(c) || crate::has_revolve(c)) { + continue; + } + let art = if !is_container(c) { + crate::reshape::own_effects_art(doc, c) + } else if has_pathfinder(c) || !crate::reshape::geometry_effects(c).is_empty() { + evaluate_container(doc, c) + } else { + None + }; + match art { + Some(a) => *c = Arc::new(a), + None if is_container(c) => members_as_drawn(doc, Arc::make_mut(c)), + None => {} + } + } +} + +thread_local! { + /// How many [`members_as_drawn`] calls are running, one inside another, and how many members + /// they have visited since the outermost one started. + static DRAWN: std::cell::Cell<(usize, u64)> = const { std::cell::Cell::new((0, 0)) }; +} + +/// One running [`members_as_drawn`] call, counted in [`DRAWN`] until it is dropped. +struct DrawnFrame; + +impl DrawnFrame { + /// Count one more call; `None` when [`vectorcraft_doc::depth::MAX_DEPTH`] are running. The + /// outermost call starts the count of members again. + fn enter() -> Option { + let (frames, members) = DRAWN.with(|d| d.get()); + if frames >= vectorcraft_doc::depth::MAX_DEPTH { + return None; + } + DRAWN.with(|d| d.set((frames + 1, if frames == 0 { 0 } else { members }))); + Some(DrawnFrame) + } + + /// Count one more member visited; `false` once [`crate::SYMBOL_ART_MAX_NODES`] have been. + fn take_member() -> bool { + let (frames, members) = DRAWN.with(|d| d.get()); + if members >= crate::SYMBOL_ART_MAX_NODES { + return false; + } + DRAWN.with(|d| d.set((frames, members + 1))); + true + } +} + +impl Drop for DrawnFrame { + fn drop(&mut self) { + DRAWN.with(|d| { + let (frames, members) = d.get(); + d.set((frames.saturating_sub(1), members)); + }); + } +} + #[cfg(test)] mod tests; diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index c169c807b2..c6e2a78c73 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -555,6 +555,298 @@ fn a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_dist assert!(near(s, Point::new(0.0, 5.0)) && near(e, Point::new(20.0, 5.0)), "{s:?} {e:?}"); } +/// The bounds of what `n` draws, as the renderer draws it: a group or layer with an appearance of +/// its own as the art [`evaluate_container`] makes of it, and each visible path and compound path +/// with the geometry effects still on it applied, then those of each visible fill and stroke that +/// has some. +fn drawn_bounds(n: &Node) -> Option { + if !n.visible { + return None; + } + if let Some(m) = evaluate_container(None, n) { + return drawn_bounds(&m); + } + if let Some(ch) = n.children() + && !matches!(n.kind, NodeKind::Compound { .. }) + { + return ch.iter().filter_map(|c| drawn_bounds(c)).reduce(|a, b| a.union(b)); + } + let (path, _) = crate::reshape::leaf_geometry(n)?; + let geometry = |fx: &[Effect]| -> Vec { fx.iter().filter(|e| e.visible && crate::is_geometry(&e.id)).cloned().collect() }; + let apply = |fx: &[Effect], p: &PathData| crate::apply_geometry(&geometry(fx), p, p.bounds().unwrap()); + let g = apply(&n.appearance.effects, &path); + let items: Vec<&AppearanceItem> = n.appearance.items.iter().filter(|i| paints(i) && !geometry(i.effects()).is_empty()).collect(); + if items.is_empty() { + return g.bounds(); + } + items.iter().filter_map(|i| apply(i.effects(), &g).bounds()).reduce(|a, b| a.union(b)) +} + +fn black() -> Appearance { + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0) +} + +/// A black 100 pt square from (100, 100). +fn square_at_100() -> Node { + Node::path(NodeId(1), shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)), black()) +} + +fn effect(id: &str, params: serde_json::Value) -> Effect { + Effect { id: id.into(), params, visible: true } +} + +fn offset_10() -> Effect { + effect("path.offsetPath", serde_json::json!({"offset": 10})) +} + +fn scaled(s: f64) -> Effect { + effect("distort.transform", serde_json::json!({"scaleH": s, "scaleV": s})) +} + +fn with_effect(mut n: Node, e: Effect) -> Node { + n.appearance.effects.push(e); + n +} + +/// The width that a group with `e` holding `member` draws at. +fn drawn_width(member: Node, e: Effect) -> f64 { + let g = with_effect(Node::group(NodeId(10), vec![Arc::new(member)]), e); + drawn_bounds(&g).unwrap().width() +} + +/// A member's own Offset Path runs before its group's Transform: the square offset by 10 pt to +/// 120 pt, then scaled by half, is 60 pt wide (70 pt the other way round). +#[test] +fn a_members_own_offset_runs_before_its_groups_scale() { + let w = drawn_width(with_effect(square_at_100(), offset_10()), scaled(50.0)); + assert!((w - 60.0).abs() < 1e-6, "{w}"); +} + +/// A member's own Transform runs before its group's Offset Path: the square scaled to 200 pt, then +/// offset by 10 pt, is 220 pt wide (240 pt the other way round). +#[test] +fn a_members_own_scale_runs_before_its_groups_offset() { + let w = drawn_width(with_effect(square_at_100(), scaled(200.0)), offset_10()); + assert!((w - 220.0).abs() < 1e-6, "{w}"); +} + +/// A member inside a plain group inside the group takes its own effects first too. +#[test] +fn a_member_of_a_plain_nested_group_takes_its_own_effects_first() { + let w = drawn_width(Node::group(NodeId(5), vec![Arc::new(with_effect(square_at_100(), offset_10()))]), scaled(50.0)); + assert!((w - 60.0).abs() < 1e-6, "{w}"); +} + +/// A nested group's own geometry effects run before the outer group's. +#[test] +fn a_nested_groups_own_effects_run_before_its_groups() { + let w = drawn_width(with_effect(Node::group(NodeId(5), vec![Arc::new(square_at_100())]), offset_10()), scaled(50.0)); + assert!((w - 60.0).abs() < 1e-6, "{w}"); +} + +/// A nested group's Pathfinder effect runs before the outer group's geometry effects: the back +/// square minus the front one is 50 pt wide, and offset by 10 pt it is 70 pt (offsetting the +/// squares first leaves 50 pt). +#[test] +fn a_nested_groups_pathfinder_runs_before_its_groups_effects() { + let back = square_at_100(); + let front = Node::path(NodeId(2), shapes::rectangle(Rect::new(150.0, 100.0, 250.0, 200.0)), black()); + let nested = with_effect(Node::group(NodeId(5), vec![Arc::new(back), Arc::new(front)]), effect("pathfinder.subtract", serde_json::json!({}))); + let w = drawn_width(nested, offset_10()); + assert!((w - 70.0).abs() < 1e-6, "{w}"); +} + +/// The geometry effects of a member's own fill run before its group's: 60 pt, as for the +/// member's own effect. +#[test] +fn a_members_fill_effects_run_before_its_groups() { + let mut member = square_at_100(); + member.appearance.items[0].effects_mut().push(offset_10()); + let w = drawn_width(member, scaled(50.0)); + assert!((w - 60.0).abs() < 1e-6, "{w}"); +} + +/// A member whose fills have geometry effects stays one element of a knockout group: its fills do +/// not knock each other out. +#[test] +fn a_member_drawn_as_its_fills_stays_one_knockout_element() { + let mut member = square_at_100(); + member.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(Paint::solid(Color::rgb(1.0, 0.0, 0.0))))); + member.appearance.items[0].effects_mut().push(offset_10()); + let mut g = with_effect(Node::group(NodeId(10), vec![Arc::new(member)]), scaled(50.0)); + g.knockout = vectorcraft_doc::Knockout::On; + let art = evaluate_container(None, &g).unwrap(); + let elements = Node::knockout_elements(art.children().unwrap()); + assert_eq!(elements.len(), 1, "{:?}", elements.iter().map(|e| (e.id, &e.kind)).collect::>()); +} + +/// The art a Pathfinder operation or compound shape takes from a group with geometry effects has +/// the members' own effects applied first, as the canvas draws it. +#[test] +fn shape_art_of_a_group_applies_its_members_effects_first() { + let g = with_effect(Node::group(NodeId(10), vec![Arc::new(with_effect(square_at_100(), offset_10()))]), scaled(50.0)); + let art = shape_art(None, &g, None).unwrap(); + let w = drawn_bounds(&art).unwrap().width(); + assert!((w - 60.0).abs() < 1e-6, "{w}"); + let leaves = member_leaves(None, &g, None); + let w = leaves.iter().filter_map(drawn_bounds).reduce(|a, b| a.union(b)).unwrap().width(); + assert!((w - 60.0).abs() < 1e-6, "leaves: {w}"); +} + +/// A symbol instance's own geometry effects run after those of its symbol's members, as the canvas +/// draws it and in the art a Pathfinder operation takes from it. +#[test] +fn a_symbol_instances_effects_run_after_its_symbols_members_own() { + let art = Node::group(NodeId(20), vec![Arc::new(with_effect(square_at_100(), offset_10()))]); + let instance = + with_effect(Node::new(NodeId(6), NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY }), scaled(50.0)); + let drawn = crate::reshape(None, &instance, Some(&art)).unwrap(); + let w = drawn_bounds(&drawn).unwrap().width(); + assert!((w - 60.0).abs() < 1e-6, "drawn: {w}"); + let hook = move |_: &Node| Some(art.clone()); + let w = drawn_bounds(&shape_art(None, &instance, Some(&hook)).unwrap()).unwrap().width(); + assert!((w - 60.0).abs() < 1e-6, "shape art: {w}"); +} + +/// Export bakes the group as the canvas draws it. +#[test] +fn a_baked_group_applies_its_members_effects_first() { + let mut d = Document::new(800.0, 800.0); + let layer = d.layers[0].id; + let g = with_effect(Node::group(NodeId(10), vec![Arc::new(with_effect(square_at_100(), offset_10()))]), scaled(50.0)); + d.insert(Some(layer), 0, g).unwrap(); + let baked = crate::bake_document(&d).unwrap(); + let w = baked.node(NodeId(10)).unwrap().geometric_bounds().unwrap().width(); + assert!((w - 60.0).abs() < 1e-6, "{w}"); +} + +/// A group whose Transform effect only moves and copies keeps its type when baked; a nested +/// group's own Transform still runs before the copies: the nested square scaled by half to 50 pt +/// and copied 200 pt to the right spans 250 pt (copying first, then scaling both copies about +/// their middle, spans 150 pt). +#[test] +fn a_baked_group_that_keeps_its_type_applies_its_members_effects_first() { + if skip_without_craft_fonts() { + return; + } + let style = CharStyle { size: 24.0, fill: Paint::solid(Color::BLACK), ..Default::default() }; + let text = Node::new(NodeId(3), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 60.0), "Type", style)))); + let nested = with_effect(Node::group(NodeId(5), vec![Arc::new(square_at_100())]), scaled(50.0)); + let g = with_effect( + Node::group(NodeId(10), vec![Arc::new(text), Arc::new(nested)]), + effect("distort.transform", serde_json::json!({"moveH": 200, "copies": 1})), + ); + let mut d = Document::new(800.0, 800.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, g).unwrap(); + let baked = crate::bake_document(&d).unwrap(); + let mut text = 0; + baked.walk(|n| text += usize::from(matches!(n.kind, NodeKind::Text(_)))); + assert_eq!(text, 2, "the type stays type, copied"); + let w = baked.node(NodeId(5)).unwrap().geometric_bounds().unwrap().width(); + assert!((w - 250.0).abs() < 1e-6, "{w}"); +} + +/// A clip group's clipping path keeps its own effects; the clipped members take theirs first. +#[test] +fn a_clipping_path_keeps_its_own_effects_under_a_groups_effects() { + let mut clip = with_effect(square_at_100(), offset_10()); + clip.id = NodeId(2); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + let clipped = + Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(with_effect(square_at_100(), offset_10()))], clip: true }); + let g = with_effect(Node::group(NodeId(10), vec![Arc::new(clipped)]), scaled(50.0)); + let art = evaluate_container(None, &g).unwrap(); + let clip_group = art.children().unwrap()[0].clone(); + let ch = clip_group.children().unwrap(); + assert!(matches!(ch[0].kind, NodeKind::Path { clipping: true, .. }), "{:?}", ch[0].kind); + assert_eq!(crate::reshape::geometry_effects(&ch[0]).len(), 1, "the clipping path keeps its Offset Path"); + assert!(matches!(ch[1].kind, NodeKind::Group { .. }) && crate::reshape::geometry_effects(&ch[1]).is_empty(), "the member is drawn as its art"); +} + +/// A member with a linked opacity mask keeps its own effects on it, so its mask moves with it +/// under the group's Transform; so does a member with a 3D effect, whose later geometry effects +/// apply to its 3D art. +#[test] +fn members_with_linked_masks_or_3d_effects_keep_their_own_effects() { + let mut masked = with_effect(square_at_100(), offset_10()); + masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new( + Node::path(NodeId(2), shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)), black()), + true, + ))); + let mut solid = with_effect(square_at_100(), effect(crate::solids::EXTRUDE, serde_json::json!({}))); + solid.id = NodeId(3); + solid.appearance.effects.push(scaled(50.0)); + let moved = effect("distort.transform", serde_json::json!({"moveH": 50, "strokes": true})); + let g = with_effect(Node::group(NodeId(10), vec![Arc::new(masked), Arc::new(solid)]), moved); + let art = evaluate_container(None, &g).unwrap(); + let ch = art.children().unwrap(); + assert!(matches!(ch[0].kind, NodeKind::Path { .. }) && crate::reshape::geometry_effects(&ch[0]).len() == 1, "{:?}", ch[0].kind); + let mask = ch[0].extras().mask.as_deref().unwrap().art.geometric_bounds().unwrap(); + assert!((mask.x0 - 150.0).abs() < 1e-6, "the mask moves with the square: {mask:?}"); + assert!(matches!(ch[1].kind, NodeKind::Path { .. }) && ch[1].appearance.effects.len() == 2, "{:?}", ch[1].kind); +} + +/// A nested group with a 3D effect stays as it is, with its members: the Transform listed after +/// its Extrude still applies to its 3D art. +#[test] +fn a_nested_group_with_a_3d_effect_keeps_its_own_effects() { + let inner = with_effect(square_at_100(), offset_10()); + let mut solid = with_effect(Node::group(NodeId(5), vec![Arc::new(inner)]), effect(crate::solids::EXTRUDE, serde_json::json!({}))); + solid.appearance.effects.push(scaled(50.0)); + let moved = effect("distort.transform", serde_json::json!({"moveH": 50})); + let g = with_effect(Node::group(NodeId(10), vec![Arc::new(solid)]), moved); + let art = evaluate_container(None, &g).unwrap(); + let ch = art.children().unwrap(); + assert!( + matches!(ch[0].kind, NodeKind::Group { .. }) && ch[0].id == NodeId(5) && ch[0].appearance.effects.len() == 2, + "{:?}", + ch[0].appearance.effects + ); + let member = &ch[0].children().unwrap()[0]; + assert!(matches!(member.kind, NodeKind::Path { .. }) && crate::reshape::geometry_effects(member).len() == 1, "{:?}", member.kind); +} + +/// Members nested deeper than the depth limit keep their own effects as they are. +#[test] +fn members_past_the_depth_limit_stay_as_they_are() { + let mut n = with_effect(square_at_100(), offset_10()); + for i in 0..vectorcraft_doc::depth::MAX_DEPTH { + n = Node::group(NodeId(100 + i as u64), vec![Arc::new(n)]); + } + let g = with_effect(Node::group(NodeId(10), vec![Arc::new(n)]), scaled(50.0)); + let art = evaluate_container(None, &g).unwrap(); + let mut deepest = None; + art.walk(&mut |c: &Node| { + if c.id == NodeId(1) { + deepest = Some(c.clone()); + } + }); + let deepest = deepest.unwrap(); + assert!(matches!(deepest.kind, NodeKind::Path { .. }), "{:?}", deepest.kind); + assert_eq!(crate::reshape::geometry_effects(&deepest).len(), 1); +} + +/// The depth limit counts nested groups with effects of their own too. +#[test] +fn nested_groups_with_effects_count_toward_the_depth_limit() { + let mut n = with_effect(square_at_100(), offset_10()); + for i in 0..vectorcraft_doc::depth::MAX_DEPTH { + n = with_effect(Node::group(NodeId(100 + i as u64), vec![Arc::new(n)]), effect("distort.transform", serde_json::json!({"moveH": 1}))); + } + let g = with_effect(Node::group(NodeId(10), vec![Arc::new(n)]), scaled(50.0)); + let art = evaluate_container(None, &g).unwrap(); + let mut deepest = None; + art.walk(&mut |c: &Node| { + if c.id == NodeId(1) && matches!(c.kind, NodeKind::Path { .. }) { + deepest = Some(c.clone()); + } + }); + assert!(deepest.is_some_and(|d| crate::reshape::geometry_effects(&d).len() == 1), "the deepest member keeps its effect"); +} + /// An image or a symbol instance that a group's Transform effect moves takes the placed gradients /// of its own fills along, as a path does, and each copy takes them as many times as it takes the /// transform. diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 2bda0aa88f..c5aaed7309 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -524,9 +524,10 @@ fn with_id(art: &Node, id: NodeId) -> Node { /// `n` as plain art that geometry effects can reshape, without its transparency and object /// effects: type outlined ([`outline_text`]), live objects evaluated (type inside them outlined), /// a symbol instance as `symbol_art` (the symbol's art, stained as the caller draws it) placed by -/// the instance's transform (through the symbol's 9-slice guides in `doc`, when it has them), its -/// fills and strokes carrying the geometry effects of the instance's active fill and stroke -/// ([`add_instance_row_effects`]). +/// the instance's transform (through the symbol's 9-slice guides in `doc`, when it has them), as +/// the art its members draw when the instance has geometry effects of its own +/// ([`crate::group::members_as_drawn`]), its fills and strokes carrying the geometry effects of the +/// instance's active fill and stroke ([`add_instance_row_effects`]). /// `None` for other kinds (images, paths, containers), and for an instance whose art placed /// through the guides could hold more than [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects. A Repeat counts the Repeats in the art of /// `doc`'s symbols that its instances show toward its instance budget. @@ -562,20 +563,16 @@ fn outline_art_with( Node::group(n.id, vec![Arc::new(a)]) } // The object as the one piece the copies of its Transform effect are made from. - _ if has_transform_art(n) || (apart && has_transform_copies(n)) => { - let mut one = n.clone(); - one.opacity = 1.0; - one.blend = Default::default(); - one.isolate = false; - one.extras_mut().mask = None; - one.appearance.effects.clear(); - Node::group(n.id, vec![Arc::new(one)]) - } + _ if has_transform_art(n) || (apart && has_transform_copies(n)) => one_piece(n), _ if vectorcraft_doc::live::is_live(n) => evaluated_with(symbols, n, outline, nodes), _ => return None, }; outline_nested_text(paint_doc, &mut art, false); if matches!(n.kind, NodeKind::SymbolInstance { .. }) { + // The instance's own geometry effects reshape what the symbol's members draw. + if !geometry_effects(n).is_empty() { + crate::group::members_as_drawn(paint_doc, &mut art); + } add_instance_row_effects(symbols, n, &mut art); } art.opacity = 1.0; @@ -843,18 +840,64 @@ fn as_art_opts( } // The art is a group with `n`'s id (for an image without fills or strokes that paint, a clip // group). - let mut out = art; - out.name = n.name.clone(); - out.visible = n.visible; - out.opacity = n.opacity; - out.blend = n.blend; - out.isolate = n.isolate; + Some(dressed(art, n)) +} + +/// `art`, the art made of `n`, with `n`'s name, visibility, transparency, opacity mask and +/// remaining (raster) effects. +fn dressed(mut art: Node, n: &Node) -> Node { + art.name = n.name.clone(); + art.visible = n.visible; + art.opacity = n.opacity; + art.blend = n.blend; + art.isolate = n.isolate; // One element: its pieces never knock each other out. - out.knockout = vectorcraft_doc::Knockout::Off; - out.knockout_shape = n.knockout_shape; - out.extras_mut().mask = n.extras().mask.clone(); - out.appearance.effects = crate::raster::without_geometry(&n.appearance.effects); - Some(out) + art.knockout = vectorcraft_doc::Knockout::Off; + art.knockout_shape = n.knockout_shape; + art.extras_mut().mask = n.extras().mask.clone(); + art.appearance.effects = crate::raster::without_geometry(&n.appearance.effects); + art +} + +/// A group with `n`'s id holding `n` without its transparency, opacity mask and effects: the one +/// piece that `n`'s own geometry effects reshape. +fn one_piece(n: &Node) -> Node { + let mut one = n.clone(); + one.opacity = 1.0; + one.blend = Default::default(); + one.isolate = false; + one.extras_mut().mask = None; + one.appearance.effects.clear(); + Node::group(n.id, vec![Arc::new(one)]) +} + +/// Path or compound path `n` with its own geometry effects applied, as the art it draws: with +/// geometry effects in a visible fill or stroke of its own, a group of one path for each fill and +/// stroke, its effects and that fill's or stroke's applied ([`crate::bake_type_appearance`]); +/// otherwise a group with `n`'s id holding the path reshaped by its effects ([`reshape_leaves`]). +/// The art keeps `n`'s id, name, visibility, transparency, opacity mask and raster effects. `None` +/// for a guide, for other kinds of objects, for a path with a linked opacity mask or a 3D effect, +/// and when `n` has no visible geometry effect of its own or in a visible fill or stroke. +pub(crate) fn own_effects_art(doc: Option<&Document>, n: &Node) -> Option { + if !matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. }) + || n.extras().mask.as_ref().is_some_and(|m| m.linked) + || crate::has_solid(n) + || crate::has_revolve(n) + { + return None; + } + if n.appearance.items.iter().any(|i| crate::paints(i) && crate::has_geometry(i.effects())) { + // One element: its pieces never knock each other out. + return crate::bake_type_appearance(doc, n).map(|art| Node { knockout: vectorcraft_doc::Knockout::Off, ..art }); + } + let fx = geometry_effects(n); + if fx.is_empty() { + return None; + } + let mut art = one_piece(n); + reshape_leaves(&mut art, &fx, &n.appearance.effects, doc); + transform_items(&mut art, doc); + Some(dressed(art, n)) } /// Map every path under `art` (and its gradients), and every path in the art of the linked opacity diff --git a/crates/effects/src/tests/rasterfollow.rs b/crates/effects/src/tests/rasterfollow.rs index e0c7bc5130..aead8b72ce 100644 --- a/crates/effects/src/tests/rasterfollow.rs +++ b/crates/effects/src/tests/rasterfollow.rs @@ -523,11 +523,12 @@ fn a_groups_transform_applies_after_its_members_own() { let mut on_fill = fill_square(33, vec![shadow(10.0, 0.0, 0.0)]); on_fill.appearance.effects = vec![turn()]; let art = crate::evaluate_container(None, &group(32, vec![on_fill], vec![uneven()])).unwrap(); - assert!(near(&[fill_shadow(&node_in(&art, 33))], &[want]), "fill: {:?}", fill_shadow(&node_in(&art, 33))); + // The member is drawn as its art: a group with its id holding the turned square. + let filled = node_in(&art, 33).children().and_then(|ch| ch.first().map(|c| (**c).clone())).unwrap(); + assert!(near(&[fill_shadow(&filled)], &[want]), "fill: {:?}", fill_shadow(&filled)); let nested = group(34, vec![group(35, vec![square(36, vec![shadow(10.0, 0.0, 0.0)])], vec![turn()])], vec![uneven()]); let art = crate::evaluate_container(None, &nested).unwrap(); - let inner = crate::evaluate_container(None, &node_in(&art, 35)).unwrap(); - assert!(near(&shadows_in(&inner), &[want]), "nested: {:?}", shadows_in(&inner)); + assert!(near(&shadows_in(&art), &[want]), "nested: {:?}", shadows_in(&art)); } /// An object's Transform effect transforms the raster effects of its fills and strokes with the @@ -645,7 +646,8 @@ fn a_groups_transform_moves_its_members_fills_shadows_beside_their_own_transform let art = crate::evaluate_container(None, &g).unwrap(); for (id, _, want) in &cases { let member = node_in(&art, *id); - let drawn = shadows_in(&crate::reshape(None, &member, None).unwrap()); + // The member is drawn as its art, its fill's own Transform effect applied. + let drawn = shadows_in(&member); assert!(!drawn.is_empty() && near(&drawn, &vec![*want; drawn.len()]), "Scale Strokes & Effects {strokes}: {id} {drawn:?}"); } } diff --git a/crates/engine/src/tests/expand.rs b/crates/engine/src/tests/expand.rs index 3fd187016f..d853b91110 100644 --- a/crates/engine/src/tests/expand.rs +++ b/crates/engine/src/tests/expand.rs @@ -535,9 +535,9 @@ fn expanded_art_keeps_the_random_transforms_the_canvas_drew() { } /// The reply lists each object whose appearance expanded once, by the id it has in the document, -/// starting with the selected object. In "group copies" the two copies of the square share an id -/// until Expand Appearance gives them ids of their own, and the reply names the group, then each -/// copy by its new id. +/// starting with the selected object. In "group copies" the group's art holds the square with its +/// stroke's effect applied, so the reply names the group alone; the two copies of the square share +/// ids until Expand Appearance gives every object in them an id of its own. #[test] fn the_reply_names_each_expanded_copy_by_its_own_id() { for case in RANDOM_CASES { @@ -551,8 +551,17 @@ fn the_reply_names_each_expanded_copy_by_its_own_id() { assert!(ids.iter().all(|&i| s.doc().unwrap().doc.node(NodeId(i)).is_some()), "{case}: {ids:?}"); if case == "group copies" { let art = node(&s, id); - let copies: Vec = art.children().unwrap()[0].children().unwrap().iter().map(|c| c.id.0).collect(); - assert_eq!(ids, [vec![id.0], copies].concat(), "{case}"); + assert_eq!(ids, [id.0], "{case}"); + let copies = art.children().unwrap(); + assert_eq!(copies.len(), 2, "{case}"); + let mut seen = std::collections::HashSet::new(); + let mut repeated = vec![]; + art.walk(&mut |n| { + if !seen.insert(n.id) { + repeated.push(n.id); + } + }); + assert!(repeated.is_empty(), "{case}: {repeated:?}"); } } } diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 38918cf2c4..1c931e0de8 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -257,6 +257,25 @@ fn shadows_under_a_groups_or_an_objects_transform_expand_appearance_as_the_canva } } +/// A group's Transform effect runs after its member's own Offset Path in Expand Appearance as on +/// the canvas: the 100 pt square offset to 120 pt and scaled by half expands 60 pt wide. +#[test] +fn a_groups_effects_after_its_members_own_expand_as_the_canvas_draws_them() { + let mut s = session(); + run(&mut s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100})); + run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 10}})); + run(&mut s, "object.group", json!({})); + run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": {"scaleH": 50, "scaleV": 50}})); + let before = render(&s); + run(&mut s, "effect.expandAppearance", json!({})); + let after = render(&s); + let diff = differing(&before, &after, 64); + assert_eq!(diff, 0, "{diff} pixels differ"); + let doc = &s.doc().unwrap().doc; + let w = doc.node(doc.layers[0].id).and_then(|l| l.geometric_bounds()).unwrap().width(); + assert!((w - 60.0).abs() < 1e-6, "{w}"); +} + #[test] fn a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it() { // The object's own effect, and the fill's (the first appearance item). diff --git a/crates/render/src/renderer/nodes.rs b/crates/render/src/renderer/nodes.rs index 47f2dec770..f649453663 100644 --- a/crates/render/src/renderer/nodes.rs +++ b/crates/render/src/renderer/nodes.rs @@ -123,7 +123,7 @@ impl Renderer { /// ([`effects::plugin_revision`]), drop what the caches keep from plug-in effects: the effect /// geometry of paths with a plug-in effect, the cull bounds of objects that have one or hold one, /// of symbol instances and of the objects that hold them, and the art drawn for the effects of - /// objects with a plug-in effect. Rasters of raster effects and evaluations of live objects hold + /// objects that have a plug-in effect or hold one. Rasters of raster effects and evaluations of live objects hold /// the revision in their keys when what they draw has a plug-in effect (`fx::Inputs`, /// `live::resources_key`). /// @@ -146,7 +146,8 @@ impl Renderer { if stale { self.fx_geom.retain(|_, e| !effects::has_plugin_effect(&e.node)); self.geom.retain(|_, e| !holds_plugin_effect(&e.node) && !holds_instance(&e.node)); - self.fx_arts.retain(|(node, ..)| !effects::has_plugin_effect(node)); + // The art of a group holds what its members' plug-in effects draw. + self.fx_arts.retain(|(node, ..)| !holds_plugin_effect(node)); } } diff --git a/crates/render/src/tests/memory.rs b/crates/render/src/tests/memory.rs index f95735baec..011f9fd7fe 100644 --- a/crates/render/src/tests/memory.rs +++ b/crates/render/src/tests/memory.rs @@ -1716,7 +1716,8 @@ fn installing_a_plugin_keeps_what_is_kept_for_other_objects() { } /// The art drawn for an object's effects follows the plug-ins installed: in the next frame, a group -/// with a plug-in effect, the same group feathered (Feather clips to the art's outline), an +/// with a plug-in effect, the same group feathered (Feather clips to the art's outline), a group +/// with a Transform effect holding a square with a plug-in effect, an /// instance of symbol `S` ([`symbol_art`]) and type, each with a plug-in effect, are drawn as the /// plug-in installed again with another shape draws them. #[test] @@ -1738,6 +1739,13 @@ fn effect_art_follows_the_plugins_installed() { plugin_change_draws_as_new(group(vec![]), ID, small.clone(), big.clone()); let feather = effect("stylize.feather", serde_json::json!({"radius": 2.0})); plugin_change_draws_as_new(group(vec![feather]), ID, small.clone(), big.clone()); + // A group with a Transform effect holding a square with the plug-in effect: the group's art + // holds what the plug-in draws for the square. + let mut d = Document::new(200.0, 100.0); + let square = filled(&mut d, rectangle(Rect::new(20.0, 30.0, 60.0, 70.0)), Paint::solid(Color::BLACK), vec![plugin()]); + let mut moved = Node::group(d.alloc_id(), vec![Arc::new(square)]); + moved.appearance.effects.push(effect("distort.transform", serde_json::json!({"moveH": 10.0}))); + plugin_change_draws_as_new(with_art(d, vec![moved]), ID, small.clone(), big.clone()); let mut d = Document::new(200.0, 100.0); let art = symbol_art(&mut d); d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 91e3b142bb..fbe2bbf2ca 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -607,6 +607,7 @@ since 2026-10-01. - **Transform effect options (#885):** Effect › Distort & Transform › Transform… has visible Transform Objects, Transform Patterns and Scale Strokes & Effects checkboxes (`distort.transform` params `objects`, `patterns`, `strokes`; files without them draw as before). With Transform Objects off the geometry stays and makes no copies; Scale Strokes & Effects scales stroke weights, dashes and the fills' and strokes' effect distances by the mean scale, and Transform Patterns moves pattern tiles, each copy `k` by the transformation `k` times. Such objects draw, export and expand through the same per-copy art, so the canvas and Expand Appearance agree (engine `tests_transformfx`, effects `tests_transformfx`, the dialog test). A Transform effect in a fill's or stroke's own effects applies the same options to that fill or stroke: its weight and dashes and its pattern follow the transformation, and with copies each copy is an object of its own; the canvas, export, Expand Appearance and Use Preview Bounds use the same art (effects `tests_transformfx`). The effects listed after it in that fill's or stroke's list keep their own distances and apply to the transformed art and to each copy, as an object's later effects do after the object's Transform effect: a Drop Shadow after it keeps its offset, an Offset Path after it offsets by its own distance, and an SVG filter after it keeps its scale and the origin of its user space. With Transform Objects off, the effects before it keep their distances too. Before, Scale Strokes & Effects scaled a fill's or stroke's later effects by the mean scale, so existing documents with such effects draw differently. Effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)); how the two options treat the effects after a fill's or stroke's own Transform effect is VectorCraft's own rule, following that order (effects `tests::rasterfollow::a_fills_own_transform_leaves_the_shadow_after_it`, `tests::rasterfollow::a_fills_own_transform_leaves_the_offset_after_it`, `tests::rasterfollow::a_fills_own_transform_leaves_the_svg_filter_after_it`, `tests::transformfx::a_fills_own_transform_with_scale_strokes_and_effects_leaves_its_later_effects`; engine `plugins::a_transform_after_a_painted_fill_effect_leaves_the_later_shadow_on_every_paint`). Public reference: [Scale objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/scale-objects.html), [How to apply effects](https://helpx.adobe.com/illustrator/using/effects.html), [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html). Scores and hours unchanged. - **Raster effects before a Transform effect:** effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)). A Drop Shadow, glow, blur, Feather or pixel filter listed before a Transform effect in the same list (an object's effects, or a fill's or stroke's own) now follows that Transform effect: its sizes scale by the mean scale, and a Drop Shadow's offset turns, reflects and scales with the art. VectorCraft applies this rule with Scale Strokes & Effects on or off. Before, such an effect was drawn at its own distances around the transformed art. The canvas, export, Expand Appearance, Object › Expand, Outline Stroke and the 3D effects use the same values (effects `tests::rasterfollow`; render `a_transform_after_a_drop_shadow_scales_the_shadow_with_the_art`; engine `objexpand::a_shadow_before_a_transform_expands_as_the_canvas_draws_it`, `outlinestroke::a_shadow_before_a_transform_outlines_as_the_canvas_draws_it`). Not yet: a Transform effect that makes copies or moves at random leaves the raster effects before it as they are; a Rasterize effect before a Transform effect keeps its pixel grid and room around the art; the angles and light directions of pixel filters do not turn or reflect with the art. Scores and hours unchanged. - **Raster effects inside an object or group with a Transform effect:** effects applied to a group affect everything in it ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)). How a Transform effect changes the raster effects inside a group or object is VectorCraft's own rule. A group's Transform effect that moves it once now transforms the raster effects of its members, of their fills and strokes, of nested groups and of the art of the linked opacity masks in it, after the members' own Transform effects. A raster effect that a member's own Transform effect with copies or random holds back keeps its value through that effect and still transforms with the group's. VectorCraft applies the same rule to the raster effects of an object's fills and strokes under the object's Transform effect. Sizes scale by the mean scale and a Drop Shadow's offset turns, reflects and scales, with Scale Strokes & Effects on or off. Before, they kept their values, or with Scale Strokes & Effects on scaled by the mean scale with the offset unturned. A group's or object's Transform effect that makes copies or moves at random leaves them as before. The canvas, the redraw region, export, Expand Appearance, Object › Expand and Outline Stroke use the same values (effects `tests::rasterfollow`, `group::tests::a_compound_shapes_transform_turns_its_fills_shadow`; render `a_transform_scales_the_shadows_of_fills_and_members_with_the_art`; engine `objexpand::a_fills_shadow_under_the_objects_transform_expands_as_the_canvas_draws_it`, `objexpand::shadows_under_a_groups_or_an_objects_transform_expand_appearance_as_the_canvas_draws_them`, `outlinestroke::a_strokes_shadow_under_the_objects_transform_outlines_as_the_canvas_draws_it`). Scores and hours unchanged. +- **A group's geometry effects after its members' own:** effects applied to a group affect everything in it, and effects apply in the order they are listed ([Appearance attributes](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-graphic-styles/appearance-attributes-in-illustrator.html)). Running a group's effects after its members' own is VectorCraft's own rule. The geometry effects of a group or layer (the effects that reshape outlines, such as Distort & Transform, Offset Path and Warp) now reshape what its members draw: the geometry effects of each visible path and compound path in it and of their fills and strokes, and a nested group's own geometry and Pathfinder effects, run first, at any depth. A symbol instance's own geometry effects run after those of its symbol's members in the same way. Before, the group's effects reshaped the members' outlines and the members' own effects ran afterwards: a 100 pt square with its own 10 pt Offset Path in a group with a 50% Transform drew 70 pt wide, and it now draws 60 pt wide. Existing documents with such groups draw differently. The canvas, export, Expand Appearance and the art that Pathfinder operations and compound shapes take from such a group use the same order (effects `group::tests::a_members_own_offset_runs_before_its_groups_scale`, `group::tests::a_members_own_scale_runs_before_its_groups_offset`, `group::tests::a_member_of_a_plain_nested_group_takes_its_own_effects_first`, `group::tests::a_nested_groups_own_effects_run_before_its_groups`, `group::tests::a_nested_groups_pathfinder_runs_before_its_groups_effects`, `group::tests::a_members_fill_effects_run_before_its_groups`, `group::tests::shape_art_of_a_group_applies_its_members_effects_first`, `group::tests::a_symbol_instances_effects_run_after_its_symbols_members_own`, `group::tests::a_baked_group_applies_its_members_effects_first`, `group::tests::a_baked_group_that_keeps_its_type_applies_its_members_effects_first`, `tests::rasterfollow::a_groups_transform_applies_after_its_members_own`; engine `objexpand::a_groups_effects_after_its_members_own_expand_as_the_canvas_draws_them`). Not yet: the own geometry effects of images, symbol instances, paths with a linked opacity mask or a 3D effect, and nested groups with a 3D effect in the group still run after the group's. Scores and hours unchanged. - **Warp Options (#1760):** Effect > Warp and existing Appearance effect links share the fifteen existing styles, original schematic previews, orientation choices and percent Bend/Distortion controls. Focused tests passed for real numeric entry and slider input, Preview/Cancel, immediate Enter/OK, mixed-style edits, target/slot/visibility preservation and Undo/Redo. Each applied style stays unchanged until an explicit Style choice. Original style-menu and live-preview captures were reviewed; their numeric values were supplied programmatically. Public references: [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) and [Using effects](https://helpx.adobe.com/illustrator/using/effects.html). Warp geometry, optional Properties integration and remembered defaults are unchanged; no broader parity claim. - **Placed gradients follow the Warp effects:** under each of the fifteen Warp styles with a bend or a distortion, a gradient placed with the Gradient tool moves and scales from the object's bounds before the warp to its bounds after it, as under Free Distort; a stroke's gradient uses those bounds inflated by the stroke. This holds for an object's own Warp and for a fill's or stroke's own, on the canvas and in Object › Expand of the fill. In a group with a Warp effect, each path's and compound path's gradients follow its own bounds, a nested group's own fills and strokes follow the nested group's bounds, and the group's own fills and strokes follow the group's bounds. In the art Pathfinder and compound shapes take, a symbol instance's Warp moves the gradients of its symbol's art by the art's bounds, and a compound shape's Warp moves its gradients by the bounds of its area. A gradient fitted to the object still fits the warped outline, and a warp with no bend and no distortion leaves the gradient exactly in place. Existing documents with a Warp effect on art with a placed gradient draw differently: before, the gradient stayed where it was while the outline changed. How gradients follow the warp is VectorCraft's own rule; what the Warp effects do to the object is described in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) (effects `tests::warp_bends_when_the_warp_moves_the_outline`, `bake::tests::baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds`, `bake::tests::a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place`, `bake::tests::a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds`, `group::tests::a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds`, `group::tests::a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds`, `group::tests::shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp`; render `objectfx::a_paths_gradient_moves_and_scales_with_its_warped_bounds`; engine `objexpand::a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Warp as a Bézier patch per style:** each of the fifteen Warp styles bends the object's bounds as one Bézier patch whose control points the style sets from the bend and the size of the bounds; the horizontal distortion then scales each column of control points about its middle and the vertical distortion scales each row. The Warp effects and warp envelopes share the map, and content with no width or no height takes the middle of the patch along that side. New warps, the built-in graphic styles' Warps and warps opened from `.ai` files carry `version` 1 for it. Warps in `.vectorcraft` documents saved before it read as `version` 0 and keep their earlier look, and edits keep a warp's version; a `version` 0 warp saved to `.ai` opens again with `version` 1, since the file holds only its settings. The patches are VectorCraft's own rules; the styles and ranges are those of the Warp options in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (doc `live::tests::warp_nets_bend_each_style_as_its_own_patch`, `warp_net_arc_edges_are_concentric_circles`, `warp_net_distortion_scales_the_columns_then_the_rows`, `warp_net_takes_any_numbers`, `warp_net_bends_a_huge_box_without_overflowing`, `warp_envelopes_bend_flat_content_as_the_middle_of_the_patch`, `warp_envelopes_write_their_map_version_only_when_it_has_one`, `style_libs::tests::builtin_warp_styles_take_the_nets_map`; effects `tests::a_warp_draws_with_the_map_its_version_names`, `tests::a_warp_bends_a_flat_box_as_the_middle_of_its_patch`, `tests::new_warps_take_the_nets_version`; engine `effectedit::warps_keep_the_map_their_version_names`, `envelope::new_warp_envelopes_take_the_net_and_saved_ones_keep_their_map`; eps `ai_editing::tests::every_effect_a_graphic_style_holds_is_read_back`, `import::ai::tests::a_warp_envelope_opens_live`, `import::ai::tests::warp_envelopes_read_every_style`, `import::ai::style::tests::a_stack_of_fills_with_effects_of_their_own_is_read_as_itself`; ui-egui `dialogs::tests_warp::warp_options_edit_keeps_each_targets_map_version`). Scores and hours unchanged.