Merge pull request #3063 from nrq6993-a11y/stream/vc-feat1-g2-pathfinder-soft-mix

G2: Effect › Pathfinder › Soft Mix blends overlapping CMYK fills
This commit is contained in:
BFlat
2026-10-22 01:05:23 +07:00
committed by GitHub
28 changed files with 124 additions and 33 deletions
+82 -28
View File
@@ -13,13 +13,14 @@
use std::sync::Arc;
use serde_json::Value;
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); 11] = [
pub const PATHFINDER_EFFECTS: [(&str, &str, PathfinderOp); 12] = [
("pathfinder.add", "Add", PathfinderOp::Unite),
("pathfinder.intersect", "Intersect", PathfinderOp::Intersect),
("pathfinder.exclude", "Exclude", PathfinderOp::Exclude),
@@ -31,6 +32,7 @@ pub const PATHFINDER_EFFECTS: [(&str, &str, PathfinderOp); 11] = [
("pathfinder.crop", "Crop", PathfinderOp::Crop),
("pathfinder.outline", "Outline", PathfinderOp::Outline),
("pathfinder.hardMix", "Hard Mix", PathfinderOp::HardMix),
("pathfinder.softMix", "Soft Mix", PathfinderOp::SoftMix),
];
/// Is `id` an Effect → Pathfinder effect?
@@ -140,7 +142,14 @@ pub fn pathfinder_children(group: &Node, hook: Option<OutlineHook>) -> Option<Ve
if shapes.is_empty() {
return Some(vec![]);
}
stack = if op == PathfinderOp::HardMix {
stack = if op == PathfinderOp::SoftMix {
let rate = mixing_rate(&e.params);
po::regions(&shapes)
.into_iter()
.filter(|r| !r.path.is_empty())
.map(|r| Arc::new(soft_mix_face(r.path, &r.sources, rate, &shapes, &sources)))
.collect()
} else if op == PathfinderOp::HardMix {
po::regions(&shapes)
.into_iter()
.filter(|r| !r.path.is_empty())
@@ -157,26 +166,55 @@ pub fn pathfinder_children(group: &Node, hook: Option<OutlineHook>) -> Option<Ve
Some(stack)
}
fn mixing_rate(params: &Value) -> f64 {
params.get("mixingRate").and_then(|v| v.as_f64()).filter(|r| r.is_finite() && *r > 0.0 && *r <= 100.0).map(|r| r / 100.0).unwrap_or(0.5)
}
fn fill_cmyk(src: &Node) -> Option<[f32; 4]> {
let paint = src.appearance.fill_paint();
let color = paint.color()?;
let tinted = match paint {
Paint::Solid { tint, .. } => color.tinted(tint),
_ => color,
};
Some(tinted.to_cmyk())
}
/// One face of a Soft Mix: CMYK blended bottom to top at `rate`; no stroke.
fn soft_mix_face(path: PathData, covering: &[usize], rate: f64, shapes: &[po::Shape], sources: &[Node]) -> Node {
let rate = rate as f32;
let mut cmyk: Option<[f32; 4]> = None;
for &si in covering {
let Some(ch) = fill_cmyk(&sources[shapes[si].key as usize]) else { continue };
cmyk = Some(match cmyk {
None => ch,
Some(acc) => {
let t = rate;
let u = 1.0 - t;
[acc[0] * u + ch[0] * t, acc[1] * u + ch[1] * t, acc[2] * u + ch[2] * t, acc[3] * u + ch[3] * t]
}
});
}
let fill = cmyk.map(|c| Paint::solid(Color::cmyk(c[0], c[1], c[2], c[3]))).unwrap_or(Paint::None);
soft_mix_styled(path, covering, shapes, sources, fill)
}
/// One face of a Hard Mix: the max of each CMYK channel over every covering shape's fill; no stroke.
fn hard_mix_face(path: PathData, covering: &[usize], shapes: &[po::Shape], sources: &[Node]) -> 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 Some(ch) = fill_cmyk(&sources[shapes[si].key as usize]) else { continue };
for (out, v) in cmyk.iter_mut().zip(ch) {
*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 };
soft_mix_styled(path, covering, shapes, sources, fill)
}
fn soft_mix_styled(path: PathData, covering: &[usize], shapes: &[po::Shape], sources: &[Node], fill: Paint) -> Node {
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 {
@@ -359,19 +397,8 @@ 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();
fn overlap_cmyk(g: &Node) -> [f32; 4] {
let faces = pathfinder_children(g, None).unwrap();
assert_eq!(faces.len(), 3);
let overlap = faces
.iter()
@@ -381,10 +408,37 @@ mod tests {
})
.expect("overlap face");
assert!(overlap.appearance.stroke_paint().is_none());
let [c, m, y, k] = overlap.appearance.fill_paint().color().unwrap().to_cmyk();
overlap.appearance.fill_paint().color().unwrap().to_cmyk()
}
fn two_overlapping_cmyk_squares() -> Node {
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);
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)),
],
)
}
#[test]
fn hard_mix_takes_the_highest_of_each_cmyk_channel_and_drops_strokes() {
let mut g = two_overlapping_cmyk_squares();
g.appearance.effects.push(Effect { id: "pathfinder.hardMix".into(), params: serde_json::json!({}), visible: true });
let [c, m, y, k] = overlap_cmyk(&g);
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 soft_mix_blends_cmyk_bottom_to_top_and_drops_strokes() {
let mut g = two_overlapping_cmyk_squares();
g.appearance.effects.push(Effect { id: "pathfinder.softMix".into(), params: serde_json::json!({"mixingRate": 50.0}), visible: true });
let [c, m, y, k] = overlap_cmyk(&g);
assert!((c - 0.3).abs() < 0.02 && (m - 0.43).abs() < 0.02 && (y - 0.35).abs() < 0.02 && (k - 0.05).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();
+11 -1
View File
@@ -803,7 +803,17 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
v.push(g(id, label, WARP, WARP_DOC, json!({"bend": 50.0, "horizontal": 0.0, "vertical": 0.0, "orientation": "horizontal"})));
}
for (id, label, _) in PATHFINDER_EFFECTS {
v.push(g(id, label, PATHFINDER, "{} (groups and layers: live Pathfinder over the members)", json!({})));
if id == "pathfinder.softMix" {
v.push(g(
id,
label,
PATHFINDER,
"{mixingRate?: 1..100 % (50)} blend overlapping fills in CMYK bottom to top (groups and layers: live Pathfinder over the members)",
json!({"mixingRate": 50.0}),
));
} else {
v.push(g(id, label, PATHFINDER, "{} (groups and layers: live Pathfinder over the members)", json!({})));
}
}
v.push(g(
CROP_MARKS,
+1
View File
@@ -382,6 +382,7 @@ fn pf_label(op: PathfinderOp) -> &'static str {
PathfinderOp::Outline => "Outline",
PathfinderOp::MinusBack => "Minus Back",
PathfinderOp::HardMix => "Hard Mix",
PathfinderOp::SoftMix => "Soft Mix",
}
}
+5 -2
View File
@@ -26,7 +26,7 @@ impl Shape {
}
}
/// The ten Pathfinder panel operations.
/// Pathfinder panel and effect operations.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum PathfinderOp {
/// Shape mode: union of everything; result keeps the front-most key.
@@ -53,6 +53,9 @@ pub enum PathfinderOp {
/// 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,
/// Effect only: split into faces like Divide; overlapping faces blend fills in CMYK bottom to
/// top by a mixing rate (Illustrator Effect → Pathfinder → Soft Mix).
SoftMix,
}
/// One face of the planar arrangement of a set of shapes (Shape Builder / Live Paint face).
@@ -175,7 +178,7 @@ pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec<Shape> {
.collect()
}
PathfinderOp::Outline => outline_edges(&arr, shapes),
PathfinderOp::HardMix => regions_of(&arr)
PathfinderOp::HardMix | PathfinderOp::SoftMix => regions_of(&arr)
.into_iter()
.filter_map(|r| {
let key = shapes.get(r.top()?)?.key;
+1
View File
@@ -3004,6 +3004,7 @@
Snapping Tolerance Толеранс на прилепване
Social Square Post 1080×1080 Публикация за квадрат в социални мрежи 1080×1080
Soft Light Мека светлина
Soft Mix Мека смес
Solid Плътен
Sort by Kind Сортиране по вид
Sort by Name Сортиране по име
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2913,6 +2913,7 @@ lighting Light Světlo
Luminosity Světlost
Overlay Překrýt
Soft Light Měkké světlo
Soft Mix Měkké míchání
Crosshatch Křížové šrafování
Fine Dots Jemné tečky
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2686,6 +2686,7 @@ lighting Light Lys
Luminosity Luminans
Overlay Overlejring
Soft Light Blødt lys
Soft Mix Blød blanding
Crosshatch Krydsskravering
Fine Dots Fine prikker
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3040,6 +3040,7 @@ effect Points Punkte
Snapping Tolerance Einrasttoleranz
Social Square Post 1080×1080 Quadratischer Social-Media-Beitrag 1080×1080
Soft Light Weiches Licht
Soft Mix Weiche Mischung
Solid Gefüllt
Sort by Kind Nach Art sortieren
Sort by Name Nach Name sortieren
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1866,6 +1866,7 @@
Snap to Point Προσκόλληση σε σημείο
Snap to Unit Προσκόλληση σε μονάδα
Soft Light Απαλό φως
Soft Mix Απαλή ανάμειξη
Solid Συνεχές
Spacing: Απόσταση:
Total: Σύνολο:
1 # Ελληνικά (el) — VectorCraft
1866
1867
1868
1869
1870
1871
1872
+1
View File
@@ -3070,6 +3070,7 @@
Snapping Tolerance Tolerancia de ajuste
Social Square Post 1080×1080 Publicación cuadrada para redes 1080×1080
Soft Light Luz suave
Soft Mix Mezcla suave
Solid Sólido
Sort by Kind Ordenar por tipo
Sort by Name Ordenar por nombre
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2859,6 +2859,7 @@ lighting Light Valo
Luminosity Luminanssi
Overlay Sulautus
Soft Light Pehmeä valo
Soft Mix Pehmeä sekoitus
Crosshatch Ristiviivoitus
Fine Dots Hienot pisteet
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3067,6 +3067,7 @@
Snapping Tolerance Tolérance du magnétisme
Social Square Post 1080×1080 Publication carrée pour réseaux sociaux 1080×1080
Soft Light Lumière tamisée
Soft Mix Fusion douce
Solid Plein
Sort by Kind Trier par type
Sort by Name Trier par nom
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3702,6 +3702,7 @@ mark-style Japanese Japán
Social Tall Post 1000×1500 Magas közösségimédia-bejegyzés 1000×1500
Soft Lágy
Soft Light Lágy fény
Soft Mix Lágy keverés
Solid Kitöltött
Sort Rendezés
Sort by Hue Rendezés színárnyalat szerint
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2686,6 +2686,7 @@ lighting Light Cahaya
Luminosity Luminositas
Overlay Menimpa
Soft Light Cahaya lembut
Soft Mix Campuran lembut
Crosshatch Arsir silang
Fine Dots Titik halus
1 # Indonesian (id) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
2686
2687
2688
2689
2690
2691
2692
+1
View File
@@ -3104,6 +3104,7 @@ mark-style Japanese Giapponese
Snapping Tolerance Tolleranza di aggancio
Social Square Post 1080×1080 Post quadrato per social 1080×1080
Soft Light Luce soffusa
Soft Mix Miscela soffice
Solid Pieno
Sort by Kind Ordina per tipo
Sort by Name Ordina per nome
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2872,6 +2872,7 @@ mark-style Japanese 日本式
Snap to Unit 単位にスナップ
Snapping Tolerance スナップ許容値
Soft Light ソフトライト
Soft Mix ソフトミックス
Solid 実線
Sort by Kind 種類順に並べ替え
Sort by Name 名前順に並べ替え
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2498,6 +2498,7 @@ lighting Light სინათლე
Luminosity ნათება
Overlay გადაფარვა
Soft Light რბილი სინათლე
Soft Mix რბილი შერევა
Crosshatch ჯვარედინი დაშტრიხვა
Fine Dots წვრილი წერტილები
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2866,6 +2866,7 @@ mark-style Japanese 일본식
Snap to Unit 단위에 스냅
Snapping Tolerance 스냅 허용 오차
Soft Light 소프트 라이트
Soft Mix 소프트 혼합
Solid 단색
Sort by Kind 종류별 정렬
Sort by Name 이름별 정렬
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2700,6 +2700,7 @@ lighting Light Licht
Luminosity Luminositeit
Overlay Bedekken
Soft Light Zacht licht
Soft Mix Zachte menging
Crosshatch Kruisarcering
Fine Dots Fijne stippen
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2791,6 +2791,7 @@ lighting Light Światło
Luminosity Jasność
Overlay Nakładka
Soft Light Łagodne światło
Soft Mix Miękkie mieszanie
Crosshatch Kreskowanie krzyżowe
Fine Dots Drobne kropki
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2542,6 +2542,7 @@
Snap to Unit Ajustar à Unidade
Snapping Tolerance Tolerância de Ajuste
Soft Light Luz Suave
Soft Mix Mistura suave
Solid Sólido
Sort by Kind Ordenar por Tipo
Sort by Name Ordenar por Nome
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -3100,6 +3100,7 @@ mark-style Japanese Японский
Snapping Tolerance Допуск привязки
Social Square Post 1080×1080 Квадратный пост для соцсетей 1080×1080
Soft Light Мягкий свет
Soft Mix Мягкое смешение
Solid Сплошной
Sort by Kind Сортировать по типу
Sort by Name Сортировать по имени
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3004,6 +3004,7 @@
Snapping Tolerance Fästtolerans
Social Square Post 1080×1080 Kvadratiskt inlägg för sociala medier 1080×1080
Soft Light Mjukt ljus
Soft Mix Mjuk blandning
Solid Enfärgad
Sort by Kind Sortera efter typ
Sort by Name Sortera efter namn
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -3097,6 +3097,7 @@ effect Points Точки
Snapping Tolerance Допуск прив’язки
Social Square Post 1080×1080 Квадратний пост для соцмереж 1080×1080
Soft Light М’яке світло
Soft Mix М’яке змішування
Solid Суцільний
Sort by Kind Сортувати за типом
Sort by Name Сортувати за назвою
Can't render this file because it contains an unexpected character in line 733 and column 40.
+1
View File
@@ -3432,6 +3432,7 @@ mark-style Japanese Kiểu Nhật
Social Square Post 1080×1080 Bài đăng vuông mạng xã hội 1080×1080
Soft Mềm
Soft Light Ánh sáng mềm
Soft Mix Trộn mềm
Solid Đặc
Sort Sắp xếp
Sort by Kind Sắp xếp theo loại
1 # Vietnamese (vi) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
3432
3433
3434
3435
3436
3437
3438
+1
View File
@@ -2938,6 +2938,7 @@ font-script Japanese 汉字
Snapping Tolerance 吸附容差
Social Square Post 1080×1080 社交方形帖子 1080×1080
Soft Light 柔光
Soft Mix 柔和混合
Solid 实心
Sort by Kind 按类型排序
Sort by Name 按名称排序
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3844,6 +3844,7 @@ font-script Japanese 漢字
Snapping Tolerance 靠齊容許度
Social Square Post 1080×1080 社群正方形貼文 1080×1080
Soft Light 柔光
Soft Mix 柔和混合
Solid 實心
Sort by Kind 依類型排序
Sort by Name 依名稱排序
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2 -2
View File
@@ -23,8 +23,8 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md](
| Convert to Shape | Rectangle, Rounded Rectangle, Ellipse | 3/3 | — |
| Crop Marks | Crop Marks | 1/1 | — |
| Distort & Transform | Free Distort, Pucker & Bloat, Roughen, Transform, Tweak, Twist, Zig Zag | 7/7 | Free Distort's dialog has a handle on each corner of the object's bounds, moved by dragging or with the arrow keys; Transform's Transform Objects/Patterns and Scale Strokes & Effects options, invisible Reflect checkboxes (#885); Zig Zag's ridges are evenly spaced along each segment (#1558), and Smooth points make a continuous wave whose peaks and valleys have handles along the path (#2343); Roughen with Smooth keeps turn-backs smooth instead of cusps that draw as spikes (#1646) |
| Path | Offset Path, Outline Object, Outline Stroke | 3/3 | — |
| Pathfinder | Add, Intersect, Exclude, Subtract, Minus Back, Divide, Trim, Merge, Crop, Outline, Hard Mix, Soft Mix, Trap | 10/13 | Hard Mix, Soft Mix, Trap |
| Path | Offset Path, Outline Object, Outline Stroke | 3/3 | With Use Preview Bounds on, Align, Distribute and the Transform panel still measure type that carries Outline Object by its layout box (preview bounds leave out effects) |
| Pathfinder | Add, Intersect, Exclude, Subtract, Minus Back, Divide, Trim, Merge, Crop, Outline, Hard Mix, Soft Mix, Trap | 11/13 | Hard Mix, Trap |
| Rasterize | Rasterize… (as a live effect) | 1/1 | — |
| Stylize | Drop Shadow, Feather, Inner Glow, Outer Glow, Round Corners, Scribble | 6/6 | Scribble Options' Settings presets |
| SVG Filters | Apply SVG Filter… and the filter list | 0/1 | the whole submenu (SVG filters on import round-trip already) |