mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-25 19:06:47 -06:00
Clip Inside/Outside strokes by the shape's even-odd fill rule (#1253)
Found while testing crates/pathops/src/lib.rs:326-333 (stroke_outline_of, StrokeAlign::Inside and Outside arms calling binary(), which uses FillRule::NonZero at :82) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3a1ddf339c
commit
7df328d1ca
+43
-10
@@ -20,7 +20,7 @@ pub mod width;
|
||||
#[cfg(test)]
|
||||
mod width_tests;
|
||||
|
||||
use figcraft_doc::{BoolOp, StrokeAlign, StrokeCap, StrokeJoin, StrokeProps, VectorNetwork};
|
||||
use figcraft_doc::{BoolOp, StrokeAlign, StrokeCap, StrokeJoin, StrokeProps, VectorNetwork, WindingRule};
|
||||
use figcraft_geom::kurbo::{self, BezPath, Cap, Join, PathEl, PathSeg, Point, Shape, Vec2};
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
@@ -69,13 +69,18 @@ fn sweep<T>(f: impl FnOnce() -> Result<T, PathOpsError>) -> Result<T, PathOpsErr
|
||||
}
|
||||
|
||||
fn binary(a: &BezPath, b: &BezPath, op: linesweeper::BinaryOp) -> Result<BezPath, PathOpsError> {
|
||||
binary_by(a, b, linesweeper::FillRule::NonZero, op)
|
||||
}
|
||||
|
||||
/// [`binary`] with both operands read by fill rule `rule`.
|
||||
fn binary_by(a: &BezPath, b: &BezPath, rule: linesweeper::FillRule, op: linesweeper::BinaryOp) -> Result<BezPath, PathOpsError> {
|
||||
if a.elements().is_empty() && b.elements().is_empty() {
|
||||
return Ok(BezPath::new());
|
||||
}
|
||||
if !finite(a) || !finite(b) {
|
||||
return Err(PathOpsError::BadInput);
|
||||
}
|
||||
let contours = sweep(|| linesweeper::binary_op(&closed(a), &closed(b), linesweeper::FillRule::NonZero, op).map_err(|_| PathOpsError::BadInput))?;
|
||||
let contours = sweep(|| linesweeper::binary_op(&closed(a), &closed(b), rule, op).map_err(|_| PathOpsError::BadInput))?;
|
||||
let mut out = BezPath::new();
|
||||
for c in contours.contours() {
|
||||
out.extend(c.path.elements().iter().copied());
|
||||
@@ -323,16 +328,20 @@ pub fn stroke_outline_of(path: &BezPath, props: &StrokeProps, closed_shape: bool
|
||||
(None, None, None) => stroke_center(path, width, props),
|
||||
};
|
||||
let align = if closed_shape { props.align } else { StrokeAlign::Center };
|
||||
// Inside/Outside clip against the shape's own fill, so an even-odd shape's holes stay holes.
|
||||
let even_odd = net.is_some_and(|n| n.regions.iter().any(|r| r.winding == WindingRule::EvenOdd));
|
||||
let clip = |s: BezPath, op: linesweeper::BinaryOp| {
|
||||
if even_odd {
|
||||
// The stroke overlaps itself (non-zero): flatten it to even-odd contours first.
|
||||
simplify(&s).and_then(|s| binary_by(&s, path, linesweeper::FillRule::EvenOdd, op)).unwrap_or(s)
|
||||
} else {
|
||||
binary(&s, path, op).unwrap_or(s)
|
||||
}
|
||||
};
|
||||
let mut out = match align {
|
||||
StrokeAlign::Center => centred(w),
|
||||
StrokeAlign::Inside => {
|
||||
let s = centred(w * 2.0);
|
||||
binary(&s, path, linesweeper::BinaryOp::Intersection).unwrap_or(s)
|
||||
}
|
||||
StrokeAlign::Outside => {
|
||||
let s = centred(w * 2.0);
|
||||
binary(&s, path, linesweeper::BinaryOp::Difference).unwrap_or(s)
|
||||
}
|
||||
StrokeAlign::Inside => clip(centred(w * 2.0), linesweeper::BinaryOp::Intersection),
|
||||
StrokeAlign::Outside => clip(centred(w * 2.0), linesweeper::BinaryOp::Difference),
|
||||
};
|
||||
if let Some(st) = styled.as_ref().filter(|_| !closed_shape) {
|
||||
out.extend(st.arrowheads(w, props).elements().iter().copied());
|
||||
@@ -709,4 +718,28 @@ mod tests {
|
||||
let s = stroke_center(&line, 2.0, &props);
|
||||
assert!((s.area().abs() - 100.0).abs() < 2.0, "{}", s.area());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inside_and_outside_strokes_respect_an_even_odd_hole() {
|
||||
// A ring: both loops run the same way, so only the even-odd rule leaves the hole open.
|
||||
let mut ring = sq(0.0, 0.0, 100.0);
|
||||
ring.extend(sq(30.0, 30.0, 40.0).elements().iter().copied());
|
||||
let mut net = VectorNetwork::from_path(&ring);
|
||||
for r in &mut net.regions {
|
||||
r.winding = figcraft_doc::WindingRule::EvenOdd;
|
||||
}
|
||||
let (material, hole) = (Point::new(29.0, 50.0), Point::new(31.0, 50.0));
|
||||
let inside = StrokeProps { weight: 4.0, align: StrokeAlign::Inside, ..Default::default() };
|
||||
let s = stroke_outline_of(&ring, &inside, true, Some(&net));
|
||||
assert_ne!(s.winding(material), 0, "inside stroke misses the material");
|
||||
assert_eq!(s.winding(hole), 0, "inside stroke leaks into the hole");
|
||||
let outside = StrokeProps { align: StrokeAlign::Outside, ..inside.clone() };
|
||||
let s = stroke_outline_of(&ring, &outside, true, Some(&net));
|
||||
assert_eq!(s.winding(material), 0, "outside stroke covers the material");
|
||||
assert_ne!(s.winding(hole), 0, "outside stroke misses the hole");
|
||||
// Non-zero: the inner loop doesn't cut a hole, so its inside stroke covers both sides.
|
||||
let nz = VectorNetwork::from_path(&ring);
|
||||
let s = stroke_outline_of(&ring, &inside, true, Some(&nz));
|
||||
assert!(s.winding(material) != 0 && s.winding(hole) != 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,11 +217,12 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
|
||||
d.to_bits().hash(&mut h);
|
||||
}
|
||||
props.dashes.len().hash(&mut h);
|
||||
// Points' own caps and joins (vector networks).
|
||||
// Points' own caps and joins and the fill rule Inside/Outside clip by (vector networks).
|
||||
let net = match &n.kind {
|
||||
NodeKind::Vector { network } if network.has_point_styles(props.join) => Some(network),
|
||||
NodeKind::Vector { network } => Some(network),
|
||||
_ => None,
|
||||
};
|
||||
net.is_some_and(|n| n.regions.iter().any(|r| r.winding == figcraft_doc::WindingRule::EvenOdd)).hash(&mut h);
|
||||
for v in net.map(|n| n.vertices.as_slice()).unwrap_or_default() {
|
||||
(v.cap.map(|c| c as u8), v.join.map(|j| j as u8)).hash(&mut h);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user