mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 00:12:24 +00:00
Merge pull request #5474 from vangel76/fix/pathops-input-hardening
Path booleans: refuse out-of-range input, guard the contour pass, cuts report refusals (#5465)
This commit is contained in:
@@ -138,8 +138,12 @@ fn cut_targets(s: &Session) -> Result<Vec<NodeId>> {
|
||||
/// What a cut does to one object: its new pieces (none deletes it).
|
||||
type Plan = Vec<(NodeId, Vec<PathData>)>;
|
||||
|
||||
/// Run `cut` on every target's geometry (`None`: unchanged) and apply the result as one undo step.
|
||||
fn run_cut(s: &mut Session, cmd: &str, label: &str, cut: &dyn Fn(&PathData, FillRule) -> Option<Vec<PathData>>) -> Result<Value> {
|
||||
/// What a cut makes of one object: its pieces (`None`: unchanged), or the pathops refusal.
|
||||
type Cut = std::result::Result<Option<Vec<PathData>>, po::PathOpsError>;
|
||||
|
||||
/// Run `cut` on every target's geometry (`None`: unchanged) and apply the result as one undo step. A shape the
|
||||
/// booleans refuse (too complex, too far out) fails the command with the art untouched: it is not "nothing on that side".
|
||||
fn run_cut(s: &mut Session, cmd: &str, label: &str, cut: &dyn Fn(&PathData, FillRule) -> Cut) -> Result<Value> {
|
||||
let targets = cut_targets(s)?;
|
||||
if targets.is_empty() {
|
||||
return Err(bad(cmd, "select paths or compound paths"));
|
||||
@@ -151,7 +155,8 @@ fn run_cut(s: &mut Session, cmd: &str, label: &str, cut: &dyn Fn(&PathData, Fill
|
||||
if pd.subpaths.iter().flat_map(|sp| &sp.anchors).any(|a| !(a.p.x.is_finite() && a.p.y.is_finite())) {
|
||||
return Err(bad(cmd, "paths contain invalid coordinates"));
|
||||
}
|
||||
if let Some(pieces) = cut(&pd, rule) {
|
||||
let pieces = cut(&pd, rule).map_err(|e| bad(cmd, format!("the cut cannot be made on this art: {e}")))?;
|
||||
if let Some(pieces) = pieces {
|
||||
plan.push((id, pieces));
|
||||
}
|
||||
}
|
||||
@@ -184,9 +189,9 @@ fn reach(pd: &PathData) -> Rect {
|
||||
}
|
||||
|
||||
/// `pd ∩ region`, dropping slivers.
|
||||
fn intersect(pd: &PathData, rule: FillRule, region: &PathData) -> PathData {
|
||||
let r = po::boolean(pd, rule, region, FillRule::NonZero, BoolOp::Intersect);
|
||||
if po::area(&r, FillRule::NonZero) <= 1e-6 { PathData::default() } else { r }
|
||||
fn intersect(pd: &PathData, rule: FillRule, region: &PathData) -> std::result::Result<PathData, po::PathOpsError> {
|
||||
let r = po::try_boolean(pd, rule, region, FillRule::NonZero, BoolOp::Intersect, po::DEFAULT_PRECISION)?;
|
||||
Ok(if po::try_area(&r, FillRule::NonZero)? <= 1e-6 { PathData::default() } else { r })
|
||||
}
|
||||
|
||||
// ---------- Line Cut ----------
|
||||
@@ -198,24 +203,26 @@ fn line_cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// `pd` split by the line: the pieces on both sides, `None` when the line misses it.
|
||||
fn split_by_line(pd: &PathData, rule: FillRule, axis: &Axis) -> Option<Vec<PathData>> {
|
||||
fn split_by_line(pd: &PathData, rule: FillRule, axis: &Axis) -> Cut {
|
||||
if pd.is_closed() {
|
||||
let around = reach(pd);
|
||||
let a = intersect(pd, rule, &axis.half_plane(1.0, around));
|
||||
let b = intersect(pd, rule, &axis.half_plane(-1.0, around));
|
||||
let a = intersect(pd, rule, &axis.half_plane(1.0, around))?;
|
||||
let b = intersect(pd, rule, &axis.half_plane(-1.0, around))?;
|
||||
if a.is_empty() || b.is_empty() {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
return Some(components(&a).into_iter().chain(components(&b)).collect());
|
||||
return Ok(Some(components(&a).into_iter().chain(components(&b)).collect()));
|
||||
}
|
||||
// Open paths: the parts on each side (each pass removes the other side).
|
||||
let a = erase_outline(pd, f64::INFINITY, &|q| axis.side(q) < 0.0)?;
|
||||
let b = erase_outline(pd, f64::INFINITY, &|q| axis.side(q) >= 0.0)?;
|
||||
Some(if pd.subpaths.len() == 1 {
|
||||
let (Some(a), Some(b)) = (erase_outline(pd, f64::INFINITY, &|q| axis.side(q) < 0.0), erase_outline(pd, f64::INFINITY, &|q| axis.side(q) >= 0.0))
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(if pd.subpaths.len() == 1 {
|
||||
a.into_iter().chain(b).collect()
|
||||
} else {
|
||||
vec![PathData::new(a.into_iter().chain(b).flat_map(|p| p.subpaths).collect())]
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------- Rectangle Cut ----------
|
||||
@@ -238,25 +245,34 @@ fn rect_cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// `pd` cropped to `rect`: `None` when it lies inside, no pieces when it lies outside.
|
||||
fn crop(pd: &PathData, rule: FillRule, rect: Rect) -> Option<Vec<PathData>> {
|
||||
let b = pd.bounds()?;
|
||||
fn crop(pd: &PathData, rule: FillRule, rect: Rect) -> Cut {
|
||||
let Some(b) = pd.bounds() else { return Ok(None) };
|
||||
if rect.inflate(1e-9, 1e-9).contains_rect(b) {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
if pd.is_closed() {
|
||||
let r =
|
||||
intersect(pd, rule, &polygon(&[rect.origin(), Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), Point::new(rect.x0, rect.y1)]));
|
||||
return Some(if r.is_empty() { vec![] } else { vec![r] });
|
||||
// Wholly outside: nothing is left, whatever the booleans would make of the rectangle's coordinates.
|
||||
if rect.intersect(b).is_zero_area() {
|
||||
return Ok(Some(vec![]));
|
||||
}
|
||||
let r = intersect(
|
||||
pd,
|
||||
rule,
|
||||
&polygon(&[rect.origin(), Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), Point::new(rect.x0, rect.y1)]),
|
||||
)?;
|
||||
return Ok(Some(if r.is_empty() { vec![] } else { vec![r] }));
|
||||
}
|
||||
let inside = rect.inflate(1e-9, 1e-9);
|
||||
// Sampled at least every half of the rectangle's shorter side, so a line through it is found.
|
||||
let step = rect.width().min(rect.height()) / 2.0;
|
||||
let pieces = erase_outline(pd, step, &|q| !(q.x >= inside.x0 && q.x <= inside.x1 && q.y >= inside.y0 && q.y <= inside.y1))?;
|
||||
Some(if pd.subpaths.len() == 1 || pieces.is_empty() {
|
||||
let Some(pieces) = erase_outline(pd, step, &|q| !(q.x >= inside.x0 && q.x <= inside.x1 && q.y >= inside.y0 && q.y <= inside.y1)) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(if pd.subpaths.len() == 1 || pieces.is_empty() {
|
||||
pieces
|
||||
} else {
|
||||
vec![PathData::new(pieces.into_iter().flat_map(|p| p.subpaths).collect())]
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------- Mirror & Cut ----------
|
||||
@@ -287,7 +303,7 @@ fn mirror_cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
other => return Err(bad(C, format!("unknown axis `{other}` (vertical|horizontal|free)"))),
|
||||
};
|
||||
let sign = keep_sign(&axis, str_param(p, "keep")).map_err(|e| bad(C, e))?;
|
||||
run_cut(s, C, "Mirror & Cut", &|pd, rule| Some(mirror(pd, rule, &axis, sign)))
|
||||
run_cut(s, C, "Mirror & Cut", &|pd, rule| mirror(pd, rule, &axis, sign).map(Some))
|
||||
}
|
||||
|
||||
/// The sign of [`Axis::side`] on the side `keep` names (default: left, or top when the axis runs
|
||||
@@ -309,22 +325,22 @@ fn keep_sign(axis: &Axis, keep: Option<&str>) -> std::result::Result<f64, String
|
||||
}
|
||||
|
||||
/// The kept half of `pd` and its reflection, joined (no pieces when nothing is on the kept side).
|
||||
fn mirror(pd: &PathData, rule: FillRule, axis: &Axis, sign: f64) -> Vec<PathData> {
|
||||
fn mirror(pd: &PathData, rule: FillRule, axis: &Axis, sign: f64) -> std::result::Result<Vec<PathData>, po::PathOpsError> {
|
||||
let m = axis.reflection();
|
||||
if pd.is_closed() {
|
||||
let kept = intersect(pd, rule, &axis.half_plane(sign, reach(pd)));
|
||||
let kept = intersect(pd, rule, &axis.half_plane(sign, reach(pd)))?;
|
||||
if kept.is_empty() {
|
||||
return vec![];
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let joined = po::boolean(&kept, FillRule::NonZero, &kept.transformed(m), FillRule::NonZero, BoolOp::Union);
|
||||
return if joined.is_empty() { vec![] } else { vec![joined] };
|
||||
let joined = po::try_boolean(&kept, FillRule::NonZero, &kept.transformed(m), FillRule::NonZero, BoolOp::Union, po::DEFAULT_PRECISION)?;
|
||||
return Ok(if joined.is_empty() { vec![] } else { vec![joined] });
|
||||
}
|
||||
let kept = match erase_outline(pd, f64::INFINITY, &|q| axis.side(q) * sign < 0.0) {
|
||||
Some(pieces) => pieces.into_iter().flat_map(|p| p.subpaths).collect(),
|
||||
None => pd.subpaths.clone(),
|
||||
};
|
||||
let subs: Vec<SubPath> = kept.iter().flat_map(|sp| mirror_open(sp, axis, m)).collect();
|
||||
if subs.is_empty() { vec![] } else { vec![PathData::new(subs)] }
|
||||
Ok(if subs.is_empty() { vec![] } else { vec![PathData::new(subs)] })
|
||||
}
|
||||
|
||||
/// An open run and its reflection, joined where its ends touch the axis (closed when both do);
|
||||
@@ -399,4 +415,28 @@ mod tests {
|
||||
let out = mirror_open(&sp, &axis, axis.reflection());
|
||||
assert_eq!((out.len(), out[0].closed, out[0].anchors.len()), (1, false, 3));
|
||||
}
|
||||
|
||||
/// A shape the booleans refuse (here: so far from the origin that the sweep tolerance exceeds the precision) is an error,
|
||||
/// not "no pieces": that would make Mirror & Cut delete it and Rectangle Cut crop it to nothing.
|
||||
#[test]
|
||||
fn a_shape_the_booleans_refuse_is_an_error_not_no_pieces() {
|
||||
let far = polygon(&[
|
||||
Point::new(1e15, 1e15),
|
||||
Point::new(1e15 + 100.0, 1e15),
|
||||
Point::new(1e15 + 100.0, 1e15 + 100.0),
|
||||
Point::new(1e15, 1e15 + 100.0),
|
||||
]);
|
||||
let axis = Axis { p: Point::ZERO, d: Vec2::new(0.0, 1.0) };
|
||||
let refused = Some(po::PathOpsError::Degenerate);
|
||||
assert_eq!(mirror(&far, FillRule::NonZero, &axis, 1.0).err(), refused);
|
||||
assert_eq!(split_by_line(&far, FillRule::NonZero, &axis).err(), refused);
|
||||
// A rectangle that far out, against art near the origin, still crops it to nothing.
|
||||
let far_rect = Rect::new(1e15, 1e15, 1e15 + 100.0, 1e15 + 100.0);
|
||||
let near_art = polygon(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)]);
|
||||
assert_eq!(crop(&near_art, FillRule::NonZero, far_rect).unwrap(), Some(vec![]));
|
||||
// Near the origin the same cuts work.
|
||||
let near = polygon(&[Point::new(-50.0, 0.0), Point::new(50.0, 0.0), Point::new(50.0, 100.0), Point::new(-50.0, 100.0)]);
|
||||
assert_eq!(mirror(&near, FillRule::NonZero, &axis, 1.0).unwrap().len(), 1);
|
||||
assert_eq!(split_by_line(&near, FillRule::NonZero, &axis).unwrap().map(|p| p.len()), Some(2));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,9 +143,20 @@ pub(crate) fn eps_for<'a>(paths: impl IntoIterator<Item = &'a BezPath>) -> Resul
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((m * f64::EPSILON * 64.0).max(1e-6))
|
||||
let eps = (m * f64::EPSILON * 64.0).max(1e-6);
|
||||
// Past this the sweep tolerance is wider than a document unit: features thinner than it collapse and the call
|
||||
// would answer `Ok` with part of the art (or all of it) gone. Refuse instead, so callers keep the original.
|
||||
if eps > MAX_SWEEP_EPS {
|
||||
return Err(PathOpsError::Degenerate);
|
||||
}
|
||||
Ok(eps)
|
||||
}
|
||||
|
||||
/// The widest sweep tolerance [`eps_for`] accepts: one document unit, reached by coordinates of about 7e13. (The engine
|
||||
/// refuses coordinates far below that, so this only guards the library's own callers; it does not follow the `precision`
|
||||
/// a caller asks for.)
|
||||
const MAX_SWEEP_EPS: f64 = 1.0;
|
||||
|
||||
fn el_points(el: &kurbo::PathEl) -> impl Iterator<Item = Point> {
|
||||
use kurbo::PathEl::*;
|
||||
let v: [Option<Point>; 3] = match *el {
|
||||
@@ -329,6 +340,13 @@ impl Arrangement {
|
||||
self.contours_where(|w| pred(&self.mask(w)))
|
||||
}
|
||||
|
||||
/// [`Self::contours`] with a panic in the sweep's contour pass turned into an error, as `new` does for the
|
||||
/// sweep itself (native builds only: on the web a panic aborts, whatever is caught here). Used by the calls that can return an error; the rest have none to return and let the engine's
|
||||
/// guard report it.
|
||||
pub(crate) fn try_contours(&self, pred: impl Fn(&[bool]) -> bool) -> Result<Contours, PathOpsError> {
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.contours(pred))).map_err(|_| PathOpsError::Degenerate)
|
||||
}
|
||||
|
||||
/// All distinct non-empty coverage masks that occur next to some edge, sorted.
|
||||
pub(crate) fn distinct_masks(&self) -> Vec<Vec<bool>> {
|
||||
let mut set = BTreeSet::new();
|
||||
@@ -670,7 +688,7 @@ pub fn boolean(a: &PathData, a_rule: FillRule, b: &PathData, b_rule: FillRule, o
|
||||
/// Boolean operation with an explicit refit `precision`; errors on non-finite input.
|
||||
pub fn try_boolean(a: &PathData, a_rule: FillRule, b: &PathData, b_rule: FillRule, op: BoolOp, precision: f64) -> Result<PathData, PathOpsError> {
|
||||
let arr = Arrangement::new(vec![(fill_bezpath(a), a_rule), (fill_bezpath(b), b_rule)])?;
|
||||
let c = arr.contours(|m| op.apply(m[0], m[1]));
|
||||
let c = arr.try_contours(|m| op.apply(m[0], m[1]))?;
|
||||
let tidy = Tidy { precision, ..arr.tidy };
|
||||
Ok(all_contours_to_path(&c, &tidy))
|
||||
}
|
||||
@@ -680,7 +698,7 @@ pub fn try_boolean(a: &PathData, a_rule: FillRule, b: &PathData, b_rule: FillRul
|
||||
pub fn try_boolean_n(paths: &[(&PathData, FillRule)], pred: impl Fn(&[bool]) -> bool) -> Result<PathData, PathOpsError> {
|
||||
let bps: Vec<(BezPath, FillRule)> = paths.iter().map(|(p, r)| (fill_bezpath(p), *r)).collect();
|
||||
let arr = Arrangement::new(bps)?;
|
||||
Ok(all_contours_to_path(&arr.contours(pred), &arr.tidy))
|
||||
Ok(all_contours_to_path(&arr.try_contours(pred)?, &arr.tidy))
|
||||
}
|
||||
|
||||
/// N-ary boolean: the area where `pred(inside_flags)` holds, `inside_flags[i]` telling whether a
|
||||
@@ -816,4 +834,75 @@ mod tests {
|
||||
lens.close_path();
|
||||
assert_eq!(tidy.repair(&lens), lens);
|
||||
}
|
||||
|
||||
fn square(x: f64, y: f64, size: f64) -> PathData {
|
||||
PathData::single(vectorcraft_geom::SubPath::new(
|
||||
[(x, y), (x + size, y), (x + size, y + size), (x, y + size)]
|
||||
.iter()
|
||||
.map(|&(x, y)| vectorcraft_geom::Anchor::corner(Point::new(x, y)))
|
||||
.collect(),
|
||||
true,
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coordinates_wider_than_the_precision_are_refused_not_emptied() {
|
||||
// At 1e15 the sweep tolerance is far above a 10 unit square: both squares used to vanish and the call said `Ok`.
|
||||
let near = square(0.0, 0.0, 10.0);
|
||||
let far = square(1e15, 1e15, 10.0);
|
||||
for op in [BoolOp::Union, BoolOp::Intersect, BoolOp::Difference, BoolOp::Xor] {
|
||||
assert_eq!(
|
||||
try_boolean(&near, FillRule::NonZero, &far, FillRule::NonZero, op, DEFAULT_PRECISION),
|
||||
Err(PathOpsError::Degenerate),
|
||||
"{op:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(try_area(&far, FillRule::NonZero), Err(PathOpsError::Degenerate));
|
||||
assert_eq!(try_normalize(&far, FillRule::NonZero), Err(PathOpsError::Degenerate));
|
||||
// The infallible entry points keep answering with an empty path.
|
||||
assert!(boolean(&near, FillRule::NonZero, &far, FillRule::NonZero, BoolOp::Union).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coordinates_near_the_float_limit_do_not_reach_the_sweep() {
|
||||
// A 1e308 rectangle made linesweeper panic (a caught panic here, an abort on the web).
|
||||
let huge = square(0.0, 0.0, 1e308);
|
||||
let small = square(0.0, 0.0, 10.0);
|
||||
assert_eq!(try_area(&huge, FillRule::NonZero), Err(PathOpsError::Degenerate));
|
||||
assert_eq!(try_boolean(&huge, FillRule::NonZero, &small, FillRule::NonZero, BoolOp::Union, DEFAULT_PRECISION), Err(PathOpsError::Degenerate));
|
||||
assert_eq!(try_unite_all(&[(&huge, FillRule::NonZero), (&small, FillRule::NonZero)]), Err(PathOpsError::Degenerate));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_but_sensible_coordinates_still_work() {
|
||||
// Well inside the limit: far-out but sensible offsets.
|
||||
for at in [1e5, 1e7] {
|
||||
let u = try_boolean(
|
||||
&square(at, at, 10.0),
|
||||
FillRule::NonZero,
|
||||
&square(at + 5.0, at, 10.0),
|
||||
FillRule::NonZero,
|
||||
BoolOp::Union,
|
||||
DEFAULT_PRECISION,
|
||||
)
|
||||
.unwrap();
|
||||
let a = try_area(&u, FillRule::NonZero).unwrap();
|
||||
assert!((a - 150.0).abs() < 1.0, "at {at}: area {a}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_coordinate_limit_sits_where_the_tolerance_passes_a_unit() {
|
||||
// 100 unit squares: tolerance 0.7 at 5e13 (kept), 1.4 at 1e14 (refused).
|
||||
assert!(try_area(&square(5e13, 5e13, 100.0), FillRule::NonZero).is_ok());
|
||||
assert_eq!(try_area(&square(1e14, 1e14, 100.0), FillRule::NonZero), Err(PathOpsError::Degenerate));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(panic = "unwind")]
|
||||
fn a_panic_in_the_contour_pass_becomes_an_error() {
|
||||
let arr = Arrangement::new(vec![(fill_bezpath(&square(0.0, 0.0, 10.0)), FillRule::NonZero)]).unwrap();
|
||||
assert!(arr.try_contours(|m| m[0]).is_ok());
|
||||
assert_eq!(arr.try_contours(|_| panic!("simulated panic in the sweep")).err(), Some(PathOpsError::Degenerate));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user