Files
vectorcraft/crates/pathops/src/trap.rs
T
Taha-Mahmoodi fd345e0c56 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.
2026-10-24 03:09:08 +07:00

125 lines
5.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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:?}");
}
}