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.
This commit is contained in:
bflatastic committed 2026-10-13 06:34:12 +07:00
1 parent 1e5f430ac5
commit a046fd30fe
2 files changed
+90 -26

No files matched your search

+37 -26
View File
@@ -719,42 +719,39 @@ fn compound_make(s: &mut Session, _: &Value) -> Result<Value> {
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<Arc<Node>> = vec![];
d.node(backmost).ok_or(EngineError::NoNode(backmost))?;
let mut units: Vec<Vec<Arc<Node>>> = 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<Node>]| 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<Value> {
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<vectorcraft_doc::Shared<Appearance>>, out: &mut Vec<Vec<Arc<Node>>>) {
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 {
+53
View File
@@ -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<f64> =
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]