From a046fd30fe84b60babfbf46f9414f277fac62409 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 13 Oct 2026 03:52:56 +0700 Subject: [PATCH] Make Compound Path: the compound path of a group takes its backmost path's paint A group has no paint of its own, so a compound path made of one (outlined type) had none and drew nothing until a fill was set. The paint now comes from the backmost path, or from the compound path that painted it. --- crates/engine/src/cmd/object.rs | 63 +++++++++++++++++++-------------- crates/engine/src/tests.rs | 53 +++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index dfd96bbd..7b6ea155 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -719,42 +719,39 @@ fn compound_make(s: &mut Session, _: &Value) -> Result { let ids = selected_roots(s)?; let Some(backmost) = ids.first().copied() else { return Err(EngineError::Other("nothing selected".into())) }; let id = s.edit("Make Compound Path", |d, sel| { - let appearance = d.node(backmost).ok_or(EngineError::NoNode(backmost))?.appearance.clone(); - let mut paths: Vec> = vec![]; + d.node(backmost).ok_or(EngineError::NoNode(backmost))?; + let mut units: Vec>> = vec![]; + let mut appearance = None; for id in &ids { - let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?; - match &n.kind { - NodeKind::Path { .. } => paths.push(Arc::new(n)), - NodeKind::Compound { children, .. } => paths.extend(children.iter().cloned()), - NodeKind::Group { .. } => { - n.walk(&mut |c| { - if matches!(c.kind, NodeKind::Path { .. }) { - paths.push(Arc::new(c.clone())) - } - }); - } - _ => return Err(EngineError::Other("compound paths can only contain paths".into())), + let n = d.node(*id).ok_or(EngineError::NoNode(*id))?; + if !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Group { .. }) { + return Err(EngineError::Other("compound paths can only contain paths".into())); } + compound_members(n, &mut appearance, &mut units); } let cid = d.alloc_id(); let mut c = Node::new(cid, NodeKind::Compound { children: vec![], rule: FillRule::NonZero }); - c.appearance = appearance; + c.appearance = appearance.unwrap_or_default(); let ch = c.children_mut().ok_or(EngineError::NoNode(cid))?; // The paths in front run against the backmost one, so where they overlap it they cut holes - // under the non-zero fill rule (#1197). + // under the non-zero fill rule (#1197). A compound path's paths turn together, by its + // first: its own holes (a letter's counters) stay holes. let direction = |n: &Node| n.path_data().and_then(|pd| pd.subpaths.first()).map_or(0.0, |sp| sp.signed_area().signum()); - let back = paths.first().map_or(0.0, |p| direction(p)); - for (i, mut p) in paths.into_iter().enumerate() { - let pn = Arc::make_mut(&mut p); - pn.appearance = Appearance::default().into(); - let flip = i > 0 && back != 0.0 && direction(pn) == back; - if let NodeKind::Path { path, live, .. } = &mut pn.kind { - *live = None; - if flip { - path.reverse(); + let first = |unit: &[Arc]| unit.first().map_or(0.0, |p| direction(p)); + let back = units.first().map_or(0.0, |u| first(u)); + for (i, unit) in units.into_iter().enumerate() { + let flip = i > 0 && back != 0.0 && first(&unit) == back; + for mut p in unit { + let pn = Arc::make_mut(&mut p); + pn.appearance = Appearance::default().into(); + if let NodeKind::Path { path, live, .. } = &mut pn.kind { + *live = None; + if flip { + path.reverse(); + } } + ch.push(p); } - ch.push(p); } d.wrap(&ids, |_| c)?; sel.set([cid]); @@ -763,6 +760,20 @@ fn compound_make(s: &mut Session, _: &Value) -> Result { Ok(json!({ "id": id.0 })) } +/// The paths in `n` (a path, a compound path, or a group searched in depth) onto `out`, in paint +/// order: each path alone, a compound path's paths together. Into `paint` goes the appearance of +/// the backmost found so far: the path's own, or its compound path's, which is what painted it. A +/// group has no paint of its own to give: a group of outlined type made a compound path that drew +/// nothing. +fn compound_members(n: &Node, paint: &mut Option>, out: &mut Vec>>) { + match (&n.kind, n.children()) { + (NodeKind::Path { .. }, _) => out.push(vec![Arc::new(n.clone())]), + (NodeKind::Compound { .. }, Some(paths)) if !paths.is_empty() => out.push(paths.clone()), + (_, children) => return children.into_iter().flatten().for_each(|c| compound_members(c, paint, out)), + } + paint.get_or_insert_with(|| n.appearance.clone()); +} + /// Is `n` a compound path to release: a compound path, or a path of several subpaths (an SVG `d` /// with several `M`s, a PDF path), which is one in Illustrator too (#1163)? fn releases(n: &Node) -> bool { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index fcffaf02..f4b5361e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1596,6 +1596,59 @@ fn inspect_reports_compound_paint_and_blend() { assert!(plain.get("blend").is_none(), "Normal is omitted: {plain}"); } +/// A compound path made of a group (outlined type is one) takes the paint of the backmost path in +/// it, not the group's own (none): it drew nothing. A compound path in the group gives the paint +/// that painted its paths. +#[test] +fn compound_of_a_group_takes_its_backmost_paths_paint() { + let paint = |s: &Session, id: u64| (*s.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance).clone(); + let made = |s: &mut Session, cmd: &str| s.execute(cmd, &json!({})).unwrap()["id"].as_u64().unwrap(); + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 40.0, 40.0); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let b = rect(&mut s, 10.0, 10.0, 10.0, 10.0); + s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap(); + let red = paint(&s, a.0); + assert_ne!(red, paint(&s, b.0)); + assert_ne!(red, Default::default()); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + made(&mut s, "object.group"); + let c = made(&mut s, "object.compoundPath.make"); + assert_eq!(paint(&s, c), red, "the backmost path's paint"); + // Grouped again and joined with a path in front: the paint the compound path gave its paths. + let g = made(&mut s, "object.group"); + let front = rect(&mut s, 20.0, 20.0, 5.0, 5.0); + s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap(); + s.execute("select.set", &json!({"ids": [g, front.0]})).unwrap(); + let c = made(&mut s, "object.compoundPath.make"); + assert_eq!(paint(&s, c), red); + assert_eq!(s.doc().unwrap().doc.node(NodeId(c)).unwrap().children().unwrap().len(), 3); +} +/// Compound paths joined into one keep their own holes: each one's paths turn together, so the +/// counters of outlined letters stay open (they filled in when every path was turned alone). +#[test] +fn joined_compound_paths_keep_their_holes() { + let made = |s: &mut Session, cmd: &str| s.execute(cmd, &json!({})).unwrap()["id"].as_u64().unwrap(); + let mut s = session(); + let mut rings = vec![]; + for x in [0.0, 100.0, 200.0] { + let (outer, inner) = (rect(&mut s, x, 0.0, 40.0, 40.0), rect(&mut s, x + 10.0, 10.0, 10.0, 10.0)); + s.execute("select.set", &json!({"ids": [outer.0, inner.0]})).unwrap(); + rings.push(made(&mut s, "object.compoundPath.make")); + } + s.execute("select.set", &json!({"ids": rings})).unwrap(); + made(&mut s, "object.group"); + let c = made(&mut s, "object.compoundPath.make"); + let d = &s.doc().unwrap().doc; + let turns: Vec = + d.node(NodeId(c)).unwrap().children().unwrap().iter().map(|p| p.path_data().unwrap().subpaths[0].signed_area().signum()).collect(); + assert_eq!(turns.len(), 6); + for ring in turns.chunks(2) { + assert_eq!(ring[0], -ring[1], "a hole runs against its outline: {turns:?}"); + } + // The rings in front run against the backmost one (#1197). + assert_eq!((turns[2], turns[4]), (-turns[0], -turns[0]), "{turns:?}"); +} /// `document.node {summary: true}` answers the `document.inspect` shape for one node: /// the same fields the full-tree summary carries, without serializing the whole tree. #[test]