diff --git a/Cargo.lock b/Cargo.lock index c43ba9d9c..fe05f99a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1758,6 +1758,7 @@ dependencies = [ "effectcraft-raster", "kurbo", "linesweeper", + "log", "tiny-skia 0.12.0", ] diff --git a/crates/path/Cargo.toml b/crates/path/Cargo.toml index 2e9bc85d9..122c37fe0 100644 --- a/crates/path/Cargo.toml +++ b/crates/path/Cargo.toml @@ -14,5 +14,6 @@ effectcraft-geom = { workspace = true } effectcraft-keyframe = { workspace = true } effectcraft-raster = { workspace = true } kurbo = { workspace = true } +log = { workspace = true } tiny-skia = { workspace = true } linesweeper = "0.5" diff --git a/crates/path/src/ops.rs b/crates/path/src/ops.rs index 826554a5d..5c89ce6bc 100644 --- a/crates/path/src/ops.rs +++ b/crates/path/src/ops.rs @@ -351,6 +351,11 @@ fn round_sub(sub: &Sub, r: f64, out: &mut BezPath) { /// join. `copies` copies are made, copy *i* (0-based) offset by `amount ยท (i + 1 + copy_offset)`, /// all returned in the same compound path. pub fn offset(paths: &[BezPath], amount: f64, join: Join, miter_limit: f64, copies: f64, copy_offset: f64) -> Vec { + offset_within(paths, amount, join, miter_limit, copies, copy_offset, MAX_OPERATOR_SEGMENTS) +} + +/// [`offset`] with each path's result held to `max` elements (its first copy aside). +fn offset_within(paths: &[BezPath], amount: f64, join: Join, miter_limit: f64, copies: f64, copy_offset: f64, max: usize) -> Vec { let n = pieces_within_budget(paths, copies.round().clamp(1.0, 1000.0) as usize); paths .iter() @@ -358,7 +363,16 @@ pub fn offset(paths: &[BezPath], amount: f64, join: Join, miter_limit: f64, copi let mut out = BezPath::new(); for i in 0..n { let d = amount * (i as f64 + 1.0 + copy_offset); - out.extend(crate::offset::offset_path(p, d, join, miter_limit).elements().iter().copied()); + let copy = crate::offset::offset_path(p, d, join, miter_limit); + // The budget is spent on what `offset_path` returns (an open subpath comes back as + // a closed outline of many segments; a zero offset as the path itself): the first + // copy is always kept, and a copy that would pass the budget is not made, so the + // work stays within a small multiple of it. + if i > 0 && out.elements().len().saturating_add(copy.elements().len()) > max { + log::warn!("path: Offset Paths made {i} of {n} copies; more would pass {max} segments"); + break; + } + out.extend(copy.elements().iter().copied()); } out }) diff --git a/crates/path/src/ops/tests.rs b/crates/path/src/ops/tests.rs index 2de236e0c..e64a55b47 100644 --- a/crates/path/src/ops/tests.rs +++ b/crates/path/src/ops/tests.rs @@ -182,6 +182,25 @@ fn offset_copies() { assert!(close(o[0].bounding_box().width(), 140.0, 1e-3)); } +#[test] +fn offset_copies_are_budgeted_by_the_output_they_return() { + // An open one-segment subpath comes back as a closed outline of several elements, so the copies + // stop when the returned elements pass the budget, not when the input segments would. + let line = || { + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + p.line_to((50.0, 0.0)); + p + }; + let one = offset_within(&[line()], 5.0, Join::Round, 4.0, 1.0, 0.0, usize::MAX)[0].elements().len(); + assert!(one > 4, "{one}"); + let o = offset_within(&[line()], 5.0, Join::Round, 4.0, 50.0, 0.0, one * 3); + let moves = o[0].elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count(); + assert!((1..50).contains(&moves) && o[0].elements().len() <= one * 4, "{moves} contours, {} elements", o[0].elements().len()); + // The first copy always comes back, however small the budget. + assert!(!offset_within(&[line()], 5.0, Join::Round, 4.0, 5.0, 0.0, 0)[0].elements().is_empty()); +} + #[test] fn offset_open_path_outlines() { let mut l = BezPath::new();