From 3f3888d040b2644ee96c514c8b8f143788681073 Mon Sep 17 00:00:00 2001 From: nrq6993-a11y Date: Mon, 19 Oct 2026 09:05:10 +0300 Subject: [PATCH] =?UTF-8?q?G2:=20Effect=20=E2=80=BA=20Pathfinder=20?= =?UTF-8?q?=E2=80=BA=20Hard=20Mix=20mixes=20CMYK=20per=20face?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overlapping faces take the highest of each CMYK channel from every covering shape and drop strokes, matching Illustrator's Hard Mix effect. Co-authored-by: Cursor --- crates/effects/src/group.rs | 81 +++++++++++++++++++++++++++++--- crates/engine/src/cmd/pathops.rs | 1 + crates/pathops/src/pathfinder.rs | 10 ++++ 3 files changed, 86 insertions(+), 6 deletions(-) diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 2211a4364..72dfb3274 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -13,12 +13,13 @@ use std::sync::Arc; +use vectorcraft_doc::color::{Color, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeId, NodeKind, StrokeLayer}; use vectorcraft_geom::{FillRule, PathData, Rect}; use vectorcraft_pathops::{self as po, PathfinderOp}; /// Effect ids of the Pathfinder submenu with their operation, in Illustrator's menu order. -pub const PATHFINDER_EFFECTS: [(&str, &str, PathfinderOp); 10] = [ +pub const PATHFINDER_EFFECTS: [(&str, &str, PathfinderOp); 11] = [ ("pathfinder.add", "Add", PathfinderOp::Unite), ("pathfinder.intersect", "Intersect", PathfinderOp::Intersect), ("pathfinder.exclude", "Exclude", PathfinderOp::Exclude), @@ -29,6 +30,7 @@ pub const PATHFINDER_EFFECTS: [(&str, &str, PathfinderOp); 10] = [ ("pathfinder.merge", "Merge", PathfinderOp::Merge), ("pathfinder.crop", "Crop", PathfinderOp::Crop), ("pathfinder.outline", "Outline", PathfinderOp::Outline), + ("pathfinder.hardMix", "Hard Mix", PathfinderOp::HardMix), ]; /// Is `id` an Effect → Pathfinder effect? @@ -138,15 +140,56 @@ pub fn pathfinder_children(group: &Node, hook: Option) -> Option Node { + let mut cmyk = [0.0f32; 4]; + let mut painted = false; + for &si in covering { + let src = &sources[shapes[si].key as usize]; + let paint = src.appearance.fill_paint(); + if let Some(color) = paint.color() { + let tinted = match paint { + Paint::Solid { tint, .. } => color.tinted(tint), + _ => color, + }; + let [c, m, y, k] = tinted.to_cmyk(); + for (out, v) in cmyk.iter_mut().zip([c, m, y, k]) { + *out = out.max(v); + } + painted = true; + } + } + let fill = if painted { + Paint::solid(Color::cmyk(cmyk[0], cmyk[1], cmyk[2], cmyk[3])) + } else { + Paint::None + }; + let top = covering.last().and_then(|&si| sources.get(shapes[si].key as usize)); + let mut n = Node::path(top.map_or(NodeId(0), |s| s.id), path, Appearance::basic(fill, Paint::None, 0.0)); + if let Some(src) = top { + n.opacity = src.opacity; + n.blend = src.blend; + } + n +} + fn is_container(n: &Node) -> bool { matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) } @@ -320,6 +363,32 @@ mod tests { po::area(n.path_data().unwrap(), FillRule::NonZero).abs() } + #[test] + fn hard_mix_takes_the_highest_of_each_cmyk_channel_and_drops_strokes() { + let a = Appearance::basic(Paint::solid(Color::cmyk(0.2, 0.66, 0.4, 0.0)), Paint::solid(Color::BLACK), 2.0); + let b = Appearance::basic(Paint::solid(Color::cmyk(0.4, 0.2, 0.3, 0.1)), Paint::solid(Color::BLACK), 2.0); + let mut g = Node::group( + NodeId(10), + vec![ + Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), a)), + Arc::new(Node::path(NodeId(2), shapes::rectangle(Rect::new(5.0, 0.0, 15.0, 10.0)), b)), + ], + ); + g.appearance.effects.push(Effect { id: "pathfinder.hardMix".into(), params: serde_json::json!({}), visible: true }); + let faces = pathfinder_children(&g, None).unwrap(); + assert_eq!(faces.len(), 3); + let overlap = faces + .iter() + .find(|n| { + let b = n.geometric_bounds().unwrap(); + b.x0 >= 4.9 && b.x1 <= 10.1 && b.y0 <= 0.1 && b.y1 >= 9.9 + }) + .expect("overlap face"); + assert!(overlap.appearance.stroke_paint().is_none()); + let [c, m, y, k] = overlap.appearance.fill_paint().color().unwrap().to_cmyk(); + assert!((c - 0.4).abs() < 0.02 && (m - 0.66).abs() < 0.02 && (y - 0.4).abs() < 0.02 && (k - 0.1).abs() < 0.02, "{c} {m} {y} {k}"); + } + #[test] fn shape_modes_follow_the_pathfinder_panel() { let add = pathfinder_children(&group_with("pathfinder.add"), None).unwrap(); diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 06e53e186..cbed03515 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -381,6 +381,7 @@ fn pf_label(op: PathfinderOp) -> &'static str { PathfinderOp::Crop => "Crop", PathfinderOp::Outline => "Outline", PathfinderOp::MinusBack => "Minus Back", + PathfinderOp::HardMix => "Hard Mix", } } diff --git a/crates/pathops/src/pathfinder.rs b/crates/pathops/src/pathfinder.rs index aa63f0381..aa28d66d7 100644 --- a/crates/pathops/src/pathfinder.rs +++ b/crates/pathops/src/pathfinder.rs @@ -50,6 +50,9 @@ pub enum PathfinderOp { Outline, /// Front-most minus everything behind; keeps the front-most key. MinusBack, + /// Effect only: split into faces like Divide; overlapping faces take the highest of each CMYK + /// channel from every shape covering them (Illustrator Effect → Pathfinder → Hard Mix). + HardMix, } /// One face of the planar arrangement of a set of shapes (Shape Builder / Live Paint face). @@ -172,6 +175,13 @@ pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec { .collect() } PathfinderOp::Outline => outline_edges(&arr, shapes), + PathfinderOp::HardMix => regions_of(&arr) + .into_iter() + .filter_map(|r| { + let key = shapes.get(r.top()?)?.key; + Some(Shape::new(r.path, FillRule::NonZero, key)) + }) + .collect(), } }