mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 02:37:01 -06:00
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.
125 lines
5.9 KiB
Rust
125 lines
5.9 KiB
Rust
//! 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:?}");
|
||
}
|
||
}
|