Pathfinder: Trap spreads the lighter colour into the darker as an overprinting strip where solid colours meet

The Pathfinder panel menu's Trap… (object.pathfinder.trap) and Effect › Pathfinder › Trap… (pathfinder.trap, live on groups) share one geometry (vectorcraft_pathops::trap_band, vectorcraft_effects::trap): Thickness, Height/Width, Tint Reduction, Traps with Process Color and Reverse Traps, plus the effect's Precision and Remove Redundant Points. The panel's Trap is one undo step and groups the traps above the topmost selected object. Behaviour from https://helpx.adobe.com/illustrator/using/trapping.html and https://helpx.adobe.com/illustrator/using/pathfinder-panel-overview.html.
This commit is contained in:
Taha-Mahmoodi
2026-10-24 03:09:08 +07:00
committed by bflatastic
parent 422ecc0c77
commit fd345e0c56
35 changed files with 1130 additions and 4 deletions
+71
View File
@@ -108,6 +108,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
|s, _| run_pf(s, PathfinderOp::MinusBack)
),
cmd!(
"object.pathfinder.trap",
"Trap…",
["Window", "Pathfinder"],
None,
"{ids?: [id…] (selected first; default: the selection), thickness?: pt 0.01..5000 (0.25; how far the lighter colour reaches into the darker one across vertical edges), heightWidth?: % 1..1000 (100; the reach across horizontal edges as a percentage of the vertical one), tintReduction?: % 0..100 (40; the trap paints the lighter colour at this percentage of its tint), trapsWithProcessColor?: bool (false; a swatch (spot) colour's trap is painted with its CMYK process equivalent), reverseTraps?: bool (false; the darker colour spreads into the lighter one, except a rich black)} adds overprinting traps where the selected objects' visible areas of different solid colours meet, grouped directly above the topmost selected object and selected; the objects stay as they are → {ids}",
has_selection,
trap
),
cmd!(
"object.path.outlineStroke",
"Outline Stroke",
@@ -500,6 +509,68 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
Ok(ids_json(&ids))
}
// ---------- Trap ----------
/// Pathfinder panel › Trap ([`vectorcraft_render::effects::trap`]): the traps between the selected
/// objects' leaf shapes, as one group of overprinting paths above the topmost selected object.
fn trap(s: &mut Session, p: &Value) -> Result<Value> {
use vectorcraft_render::effects::trap::{TRAP_LABEL, TrapOptions, TrapSource, trap_appearance, traps};
const ID: &str = "object.pathfinder.trap";
let mut q = p.clone();
if let Some(t) = len_param(p, "thickness") {
if !(po::TRAP_MIN_WIDTH..=po::TRAP_MAX_WIDTH).contains(&t) {
return Err(bad(ID, "thickness must be from 0.01 to 5000 pt"));
}
q["thickness"] = json!(t);
}
if num_param(p, "heightWidth").is_some_and(|v| !(1.0..=1000.0).contains(&v)) {
return Err(bad(ID, "heightWidth must be from 1 to 1000 %"));
}
if num_param(p, "tintReduction").is_some_and(|v| !(0.0..=100.0).contains(&v)) {
return Err(bad(ID, "tintReduction must be from 0 to 100 %"));
}
// The effect-only settings stay at their defaults.
if let Some(o) = q.as_object_mut() {
o.remove("precision");
o.remove("removeRedundantPoints");
}
let options = TrapOptions::from_params(&q);
let roots = selected_roots(s)?;
if roots.is_empty() {
return Err(EngineError::Other("nothing selected".into()));
}
let ids = s.edit(TRAP_LABEL, |d, sel| {
let mut sources = vec![];
for id in &roots {
let n = d.node(*id).ok_or(EngineError::NoNode(*id))?;
each_leaf(n, &mut |l| {
if let Some((path, rule)) = node_path(l) {
sources.push(TrapSource { path, rule, fill: style_of(l).appearance.fill_paint() });
}
});
}
if sources.iter().filter(|s| s.fill.color().is_some()).count() < 2 {
return Err(EngineError::Other("Trap: select two or more objects filled with solid colours".into()));
}
let made = traps(&sources, &options);
if made.is_empty() {
return Err(EngineError::Other("Trap: no edges of different colours meet among the selected objects".into()));
}
let mut children = Vec::with_capacity(made.len());
for piece in made {
let mut n = shape_node(d, piece.path, None);
n.appearance = trap_appearance(piece.paint).into();
children.push(Arc::new(n));
}
let gid = d.alloc_id();
let top = *roots.last().ok_or_else(|| EngineError::Other("nothing selected".into()))?;
d.insert_beside(vec![(top, vec![Node::group(gid, children)])], false)?;
sel.set([gid]);
Ok(vec![gid])
})?;
Ok(ids_json(&ids))
}
// ---------- Offset Path ----------
fn join_param(p: &Value, key: &str) -> po::Join {
+3
View File
@@ -2620,6 +2620,8 @@ mod tests_transformfx;
#[cfg(test)]
mod tests_transparencygrid;
#[cfg(test)]
mod tests_trap;
#[cfg(test)]
mod tests_typearea;
#[cfg(test)]
mod tests_typescale;
@@ -2664,6 +2666,7 @@ pub(crate) const SELECTS_GIVEN_IDS: &[&str] = &[
"object.pathfinder.minusBack",
"object.pathfinder.minusFront",
"object.pathfinder.outline",
"object.pathfinder.trap",
"object.pathfinder.trim",
"object.pathfinder.unite",
"object.reflect",
+148
View File
@@ -0,0 +1,148 @@
//! Tests for Pathfinder panel › Trap (`object.pathfinder.trap`).
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::Rect;
use super::*;
const YELLOW: Color = Color::Cmyk { c: 0.0, m: 0.0, y: 1.0, k: 0.0 };
const BLUE: Color = Color::Cmyk { c: 1.0, m: 0.6, y: 0.0, k: 0.0 };
/// A yellow rectangle (0..50) abutting a blue one (50..100), both 40 pt tall, both selected.
fn abutting() -> (Session, NodeId, NodeId) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let mut rect = |x: f64, c: Color| {
let id = NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap());
s.edit("fill", |d, _| {
if let Some(n) = d.node_mut(id) {
n.appearance.set_fill(Paint::solid(c));
}
Ok(())
})
.unwrap();
id
};
let (a, b) = (rect(0.0, YELLOW), rect(50.0, BLUE));
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
(s, a, b)
}
fn ids(v: &Value) -> Vec<NodeId> {
v["ids"].as_array().unwrap().iter().map(|x| NodeId(x.as_u64().unwrap())).collect()
}
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().doc.node(id).cloned().unwrap()
}
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 0.02
}
/// The traps of the trap group the command made.
fn traps(s: &Session, v: &Value) -> Vec<Node> {
let g = node(s, ids(v)[0]);
assert!(matches!(g.kind, NodeKind::Group { .. }));
g.children().unwrap().iter().map(|c| (**c).clone()).collect()
}
#[test]
fn trap_spreads_the_lighter_colour_into_the_darker_along_the_shared_edge() {
let (mut s, a, b) = abutting();
let before = s.doc().unwrap().doc.clone();
let undo_len = s.doc().unwrap().history.undo.len();
let r = s.execute("object.pathfinder.trap", &json!({"thickness": 2, "tintReduction": 100})).unwrap();
let t = traps(&s, &r);
assert_eq!(t.len(), 1);
let bb = t[0].geometric_bounds().unwrap();
assert!(near(bb.x0, 50.0) && near(bb.x1, 52.0) && near(bb.y0, 0.0) && near(bb.y1, 40.0), "2 pt into the blue: {bb:?}");
let fill = t[0].appearance.fill().unwrap();
assert_eq!(fill.paint.color(), Some(YELLOW), "filled with the lighter colour");
assert!(fill.overprint, "set to overprint");
assert!(t[0].appearance.stroke_paint().is_none());
// The originals stay; the traps sit directly above the topmost one and are selected.
let st = s.doc().unwrap();
assert_eq!(node(&s, a), *before.node(a).unwrap());
assert_eq!(node(&s, b), *before.node(b).unwrap());
let (parent, ib, _) = st.doc.position(b).unwrap();
let (gparent, ig, _) = st.doc.position(ids(&r)[0]).unwrap();
assert_eq!((gparent, ig), (parent, ib + 1));
assert_eq!(st.selection.objects, ids(&r));
// One undo step.
assert_eq!(st.history.undo.len(), undo_len + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, before);
}
#[test]
fn height_width_and_tint_reduction_shape_and_tint_the_trap() {
let (mut s, _, _) = abutting();
let r = s.execute("object.pathfinder.trap", &json!({"thickness": "1 pt", "tintReduction": 25})).unwrap();
let t = traps(&s, &r);
let [_, _, y, _] = t[0].appearance.fill_paint().color().unwrap().to_cmyk();
assert!((y - 0.25).abs() < 1e-4, "yellow at 25%: {y}");
let bb = t[0].geometric_bounds().unwrap();
assert!(near(bb.x1, 51.0), "{bb:?}");
}
#[test]
fn reverse_traps_spread_the_darker_colour_into_the_lighter() {
let (mut s, _, _) = abutting();
let r = s.execute("object.pathfinder.trap", &json!({"thickness": 2, "tintReduction": 100, "reverseTraps": true})).unwrap();
let t = traps(&s, &r);
let bb = t[0].geometric_bounds().unwrap();
assert!(near(bb.x0, 48.0) && near(bb.x1, 50.0), "2 pt into the yellow: {bb:?}");
assert_eq!(t[0].appearance.fill_paint().color(), Some(BLUE));
}
#[test]
fn no_adjoining_edges_is_a_clear_error_that_changes_nothing() {
let (mut s, a, b) = abutting();
s.edit("apart", |d, _| {
if let Some(n) = d.node_mut(b) {
n.kind = NodeKind::Path {
path: vectorcraft_geom::shapes::rectangle(Rect::new(200.0, 0.0, 250.0, 40.0)),
rule: vectorcraft_geom::FillRule::NonZero,
live: None,
clipping: false,
guide: false,
};
}
Ok(())
})
.unwrap();
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
let before = s.doc().unwrap().doc.clone();
let undo_len = s.doc().unwrap().history.undo.len();
let e = s.execute("object.pathfinder.trap", &json!({})).unwrap_err().to_string();
assert!(e.contains("no edges of different colours meet"), "{e}");
assert_eq!(s.doc().unwrap().doc, before);
assert_eq!(s.doc().unwrap().history.undo.len(), undo_len);
// One object alone has nothing to trap with.
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
let e = s.execute("object.pathfinder.trap", &json!({})).unwrap_err().to_string();
assert!(e.contains("two or more objects"), "{e}");
}
#[test]
fn out_of_range_and_unreadable_numbers_are_refused_or_defaulted() {
let (mut s, _, _) = abutting();
for p in [json!({"thickness": 1e300}), json!({"thickness": 0}), json!({"heightWidth": 0}), json!({"tintReduction": 101})] {
assert!(s.execute("object.pathfinder.trap", &p).is_err(), "{p}");
}
// A thickness that isn't a number falls back to the default.
let r = s.execute("object.pathfinder.trap", &json!({"thickness": "NaN", "tintReduction": 100})).unwrap();
let bb = traps(&s, &r)[0].geometric_bounds().unwrap();
assert!(near(bb.x1, 50.25), "{bb:?}");
}
#[test]
fn ids_name_the_objects_to_trap() {
let (mut s, a, b) = abutting();
s.execute("select.set", &json!({"ids": []})).unwrap();
let r = s.execute("object.pathfinder.trap", &json!({"ids": [a.0, b.0]})).unwrap();
assert_eq!(traps(&s, &r).len(), 1);
}