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
+2
View File
@@ -12,6 +12,7 @@ mod fit;
mod offset;
mod pathfinder;
mod planar;
mod trap;
pub use boolean::{BoolOp, DEFAULT_PRECISION, area, boolean, boolean_n, normalize, try_boolean, try_normalize, unite_all};
pub use edit::{
@@ -24,6 +25,7 @@ pub use planar::{
BuilderArrangement, SHAPE_BUILDER_MAX_GAP, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, find_gaps, has_open_path, interior_point,
live_paint, live_paint_with_gaps, run_around, shape_builder, shape_builder_with_gap,
};
pub use trap::{TRAP_HEIGHT_WIDTH_RANGE, TRAP_MAX_WIDTH, TRAP_MIN_WIDTH, trap_band};
/// Errors from fallible operations.
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
+124
View File
@@ -0,0 +1,124 @@
//! The geometry of a trap (Pathfinder › Trap): the thin strip where one colour is spread into the
//! area of an adjoining colour, so a slight misregistration on press leaves no paper gap between
//! them. See `vectorcraft_effects::trap` for which colour spreads and how the strip is painted.
use kurbo::Affine;
use vectorcraft_geom::{FillRule, PathData};
use crate::boolean::{BoolOp, try_boolean, try_normalize};
use crate::offset::{Cap, Join, outline_stroke};
/// Largest trap width, in points, as the public Trap options allow (0.01–5000 pt).
pub const TRAP_MAX_WIDTH: f64 = 5000.0;
/// Smallest trap width, in points.
pub const TRAP_MIN_WIDTH: f64 = 0.01;
/// Height/Width range (horizontal edges' trap as a fraction of vertical edges').
pub const TRAP_HEIGHT_WIDTH_RANGE: (f64, f64) = (0.01, 10.0);
/// Does every anchor and handle of `p` have finite coordinates?
fn finite(p: &PathData) -> bool {
p.subpaths.iter().flat_map(|sp| sp.anchors.iter()).all(|a| [a.p, a.h_in, a.h_out].iter().all(|q| q.x.is_finite() && q.y.is_finite()))
}
/// The strip of `into` (with `into_rule`) within `width` of the edge of `spread` (with
/// `spread_rule`): where `spread`'s colour reaches when it grows by `width` across vertical edges
/// and by `width × height_width` across horizontal edges. Where the shapes overlap, the strip
/// also covers the overlap, so callers pass areas that don't overlap (the visible parts of two
/// objects). `precision` is the refit tolerance of the boolean that cuts the strip, in points.
///
/// Empty when the shapes don't come within `width` of each other, when either is empty or has a
/// coordinate that isn't finite, or when `width` or `height_width` isn't a finite positive number.
/// `width` is capped at [`TRAP_MAX_WIDTH`] and `height_width` at [`TRAP_HEIGHT_WIDTH_RANGE`].
pub fn trap_band(
spread: &PathData,
spread_rule: FillRule,
into: &PathData,
into_rule: FillRule,
width: f64,
height_width: f64,
precision: f64,
) -> PathData {
if !(width.is_finite() && width > 0.0 && height_width.is_finite() && height_width > 0.0) || spread.is_empty() || into.is_empty() {
return PathData::default();
}
if !finite(spread) || !finite(into) {
return PathData::default();
}
let w = width.clamp(TRAP_MIN_WIDTH, TRAP_MAX_WIDTH);
let r = height_width.clamp(TRAP_HEIGHT_WIDTH_RANGE.0, TRAP_HEIGHT_WIDTH_RANGE.1);
let precision = if precision.is_finite() && precision > 0.0 { precision.min(10.0) } else { crate::boolean::DEFAULT_PRECISION };
// Quick reject: bounds farther apart than the trap reaches.
let (Some(sb), Some(ib)) = (spread.bounds(), into.bounds()) else { return PathData::default() };
let reach = sb.inflate(w, w * r);
if reach.x1 < ib.x0 || ib.x1 < reach.x0 || reach.y1 < ib.y0 || ib.y1 < reach.y0 {
return PathData::default();
}
// Grow uniformly in a space squashed vertically by `r`, then stretch back: horizontal edges'
// strip comes out `r` times as tall as vertical edges' is wide.
let Ok(outline) = try_normalize(spread, spread_rule) else { return PathData::default() };
let squash = Affine::scale_non_uniform(1.0, 1.0 / r);
let ring = outline_stroke(&outline.transformed(squash), 2.0 * w, Cap::Butt, Join::Miter, 4.0);
if ring.is_empty() {
return PathData::default();
}
let ring = ring.transformed(Affine::scale_non_uniform(1.0, r));
try_boolean(&ring, FillRule::NonZero, into, into_rule, BoolOp::Intersect, precision).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use vectorcraft_geom::Rect;
use vectorcraft_geom::shapes::rectangle;
use super::*;
fn bounds(p: &PathData) -> Rect {
p.bounds().unwrap_or_default()
}
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 0.02
}
#[test]
fn abutting_rectangles_trap_along_their_shared_edge() {
let light = rectangle(Rect::new(0.0, 0.0, 50.0, 40.0));
let dark = rectangle(Rect::new(50.0, 0.0, 100.0, 40.0));
let band = trap_band(&light, FillRule::NonZero, &dark, FillRule::NonZero, 2.0, 1.0, 0.01);
let b = bounds(&band);
assert!(near(b.x0, 50.0) && near(b.x1, 52.0) && near(b.y0, 0.0) && near(b.y1, 40.0), "{b:?}");
assert!(near(crate::area(&band, FillRule::NonZero), 80.0));
}
#[test]
fn height_width_scales_the_trap_on_horizontal_edges() {
let light = rectangle(Rect::new(0.0, 0.0, 40.0, 50.0));
let dark = rectangle(Rect::new(0.0, 50.0, 40.0, 100.0));
let band = trap_band(&light, FillRule::NonZero, &dark, FillRule::NonZero, 2.0, 2.0, 0.01);
let b = bounds(&band);
assert!(near(b.y0, 50.0) && near(b.y1, 54.0), "a horizontal edge's trap is twice the width: {b:?}");
}
#[test]
fn far_apart_shapes_and_bad_numbers_give_nothing() {
let a = rectangle(Rect::new(0.0, 0.0, 10.0, 10.0));
let b = rectangle(Rect::new(30.0, 0.0, 40.0, 10.0));
assert!(trap_band(&a, FillRule::NonZero, &b, FillRule::NonZero, 2.0, 1.0, 0.01).is_empty());
let c = rectangle(Rect::new(10.0, 0.0, 20.0, 10.0));
for (w, r) in [(f64::NAN, 1.0), (f64::INFINITY, 1.0), (-1.0, 1.0), (0.0, 1.0), (1.0, f64::NAN), (1.0, 0.0)] {
assert!(trap_band(&a, FillRule::NonZero, &c, FillRule::NonZero, w, r, 0.01).is_empty(), "{w} {r}");
}
let mut bad = a.clone();
bad.subpaths[0].anchors[0].p.x = f64::NAN;
assert!(trap_band(&bad, FillRule::NonZero, &c, FillRule::NonZero, 1.0, 1.0, 0.01).is_empty());
}
#[test]
fn a_huge_width_is_capped_and_stays_inside_the_darker_shape() {
let a = rectangle(Rect::new(0.0, 0.0, 10.0, 10.0));
let c = rectangle(Rect::new(10.0, 0.0, 20.0, 10.0));
let band = trap_band(&a, FillRule::NonZero, &c, FillRule::NonZero, 1e300, 1e300, f64::NAN);
let b = bounds(&band);
assert!(near(b.x0, 10.0) && near(b.x1, 20.0), "the whole darker square at most: {b:?}");
}
}