Merge pull request #1184 from saturngod/fix/boolean-stroke-outlines

Fix stroke-only boolean operand outlines
This commit is contained in:
BFlat
2026-10-26 08:33:31 +07:00
committed by GitHub
2 changed files with 132 additions and 4 deletions
+100
View File
@@ -153,3 +153,103 @@ fn deep_boolean_nesting_stops_at_the_outline_depth_limit() {
d.create(inner, NodeKind::Rectangle, "R", 40.0, 10.0, 20.0, 20.0).unwrap();
assert!((outline(&d, b).area().abs() - 2.0 * 20.0 * 20.0).abs() < 0.5);
}
#[test]
fn stroke_only_boolean_operands_keep_their_holes() {
use figcraft_doc::StrokeAlign;
use figcraft_geom::Point;
for kind in [NodeKind::Polygon { count: 6 }, NodeKind::Rectangle, NodeKind::Line] {
for align in [StrokeAlign::Inside, StrokeAlign::Center, StrokeAlign::Outside] {
let mut d = Document::new("stroke operands");
let page = d.first_page().unwrap();
let b = d.create(page, NodeKind::BooleanOp { op: BoolOp::Subtract }, "B", 0.0, 0.0, 100.0, 100.0).unwrap();
d.get_mut(b).unwrap().fills = vec![Paint::solid(Color::BLACK)];
// A nested group exercises the same path as a direct operand.
let g = d.create(b, NodeKind::Group, "G", 0.0, 0.0, 100.0, 100.0).unwrap();
let id = d.create(g, kind.clone(), "stroke", 10.0, 10.0, 80.0, 80.0).unwrap();
let n = d.get_mut(id).unwrap();
let mut hidden_fill = Paint::solid(Color::BLACK);
hidden_fill.visible = false;
n.fills = vec![hidden_fill];
n.strokes = vec![Paint::solid(Color::BLACK)];
n.stroke.weight = 6.0;
n.stroke.align = align;
let path = outline(&d, b);
assert_eq!(path.winding(Point::new(50.0, 45.0)), 0, "{kind:?} {align:?}: empty interior");
assert!(path.area().abs() > 100.0, "{kind:?} {align:?}: stroke survives");
assert!(!dark(&render(&d, page), 50, 45));
if !matches!(kind, NodeKind::Line) {
// Changing only stroke weight invalidates the boolean result.
let area = path.area().abs();
d.get_mut(id).unwrap().stroke.weight = 3.0;
assert!(outline(&d, b).area().abs() < area);
d.get_mut(id).unwrap().fills[0].visible = true;
assert_ne!(outline(&d, b).winding(Point::new(50.0, 45.0)), 0, "visible fills keep the interior");
}
}
}
}
#[test]
fn boolean_operands_preserve_side_borders_and_container_children() {
use figcraft_doc::StrokeAlign;
use figcraft_geom::Point;
for kind in [NodeKind::Rectangle, NodeKind::Frame] {
let mut d = Document::new("borders");
let page = d.first_page().unwrap();
let b = d.create(page, NodeKind::BooleanOp { op: BoolOp::Union }, "B", 0.0, 0.0, 100.0, 100.0).unwrap();
let id = d.create(b, kind.clone(), "operand", 0.0, 0.0, 100.0, 100.0).unwrap();
let n = d.get_mut(id).unwrap();
n.fills.clear();
n.strokes = vec![Paint::solid(Color::BLACK)];
n.stroke.weight = 8.0;
n.stroke.align = StrokeAlign::Inside;
n.stroke.sides = Some([8.0, 0.0, 0.0, 0.0]);
if matches!(kind, NodeKind::Frame) {
d.create(id, NodeKind::Rectangle, "child", 40.0, 40.0, 20.0, 20.0).unwrap();
}
let p = outline(&d, b);
assert_ne!(p.winding(Point::new(50.0, 4.0)), 0, "top border");
assert_eq!(p.winding(Point::new(98.0, 50.0)), 0, "no right border");
assert_eq!(p.winding(Point::new(50.0, 98.0)), 0, "no bottom border");
if matches!(kind, NodeKind::Frame) {
assert_ne!(p.winding(Point::new(50.0, 50.0)), 0, "child retained");
}
d.get_mut(id).unwrap().stroke.sides = Some([0.0, 8.0, 0.0, 0.0]);
let p = outline(&d, b);
assert_eq!(p.winding(Point::new(50.0, 4.0)), 0, "side edit removes top");
assert_ne!(p.winding(Point::new(98.0, 50.0)), 0, "side edit adds right");
d.get_mut(id).unwrap().stroke.sides = None;
assert_ne!(outline(&d, b).winding(Point::new(50.0, 4.0)), 0, "uniform container border");
}
}
#[test]
fn stroke_only_text_operands_use_glyphs_instead_of_the_text_box() {
use figcraft_doc::{StrokeAlign, TextData};
let mut d = Document::new("text strokes");
let page = d.first_page().unwrap();
let b = d.create(page, NodeKind::BooleanOp { op: BoolOp::Union }, "B", 0.0, 0.0, 400.0, 100.0).unwrap();
let mut text = TextData::new("Hi");
text.style.font_size = 32.0;
let id = d.create(b, NodeKind::Text(text), "text", 0.0, 0.0, 400.0, 100.0).unwrap();
let n = d.get_mut(id).unwrap();
n.fills.clear();
n.strokes = vec![Paint::solid(Color::BLACK)];
n.stroke.weight = 2.0;
n.stroke.align = StrokeAlign::Center;
let glyphs = outline(&d, id);
let result = outline(&d, b);
assert_eq!(result.elements().is_empty(), glyphs.elements().is_empty());
if !glyphs.elements().is_empty() {
let expected = glyphs.bounding_box().inflate(3.0, 3.0);
let actual = result.bounding_box();
assert!(actual.x0 >= expected.x0 && actual.x1 <= expected.x1);
assert!(actual.y0 >= expected.y0 && actual.y1 <= expected.y1);
assert!(actual.x1 < 200.0, "the unused text box must not contribute a border");
}
let NodeKind::Text(text) = &mut d.get_mut(id).unwrap().kind else { panic!() };
text.characters = " ".into();
assert!(outline(&d, b).elements().is_empty(), "spaces have no stroked glyphs");
}
+32 -4
View File
@@ -209,6 +209,18 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
return Arc::new(o);
}
}
cached_stroke_outline(n, path, closed)
}
/// A non-zero stroke outline, shared by painting and boolean operands.
fn cached_stroke_outline(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
let props = &n.stroke;
if let Some(sides) = props.sides
&& matches!(n.kind, NodeKind::Rectangle | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. })
&& n.corners.uniform() == Some(0.0)
{
return Arc::new(figcraft_pathops::rect_side_strokes(n.width, n.height, sides, props.align));
}
let mut h = std::collections::hash_map::DefaultHasher::new();
hash_path(path, &mut h);
(props.weight.to_bits(), props.align as u8, props.cap_start as u8, props.cap_end as u8, props.join as u8, props.miter_limit.to_bits(), closed)
@@ -281,7 +293,7 @@ fn node_outline_at(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, F
.filter_map(|c| doc.node(*c))
.filter(|c| c.visible)
.filter_map(|c| {
let (mut p, _) = subtree_outline(doc, c, depth + 1)?;
let (mut p, _) = subtree_outline_at(doc, c, depth + 1, true)?;
p.apply_affine(c.transform);
Some((p, c.bool_op.unwrap_or(*op)))
})
@@ -305,6 +317,11 @@ fn node_outline_at(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, F
/// The union outline of a node and its descendants (for booleans of groups/frames).
pub fn subtree_outline(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, FillRule)> {
subtree_outline_at(doc, n, depth, false)
}
/// Boolean operands with only a stroke contribute the painted band, not their interior.
fn subtree_outline_at(doc: &Document, n: &Node, depth: usize, stroke_only: bool) -> Option<(BezPath, FillRule)> {
if depth > 64 {
return None;
}
@@ -313,21 +330,32 @@ pub fn subtree_outline(doc: &Document, n: &Node, depth: usize) -> Option<(BezPat
let mut out = BezPath::new();
if n.kind.is_frame_like()
&& let Some(p) = figcraft_doc::geometry::shape_path(n)
&& n.fills.iter().any(|f| f.visible)
{
out.extend(p.elements().iter().copied());
if n.fills.iter().any(|f| f.visible) {
out.extend(p.elements().iter().copied());
} else if stroke_only && n.has_visible_stroke() {
out.extend(cached_stroke_outline(n, &p, true).elements().iter().copied());
}
}
for c in n.children.iter().filter_map(|c| doc.node(*c)) {
if !c.visible {
continue;
}
if let Some((mut p, _)) = subtree_outline(doc, c, depth + 1) {
if let Some((mut p, _)) = subtree_outline_at(doc, c, depth + 1, stroke_only) {
p.apply_affine(c.transform);
out.extend(p.elements().iter().copied());
}
}
Some((out, FillRule::Winding))
}
_ if stroke_only && !n.fills.iter().any(|p| p.visible) && n.has_visible_stroke() => {
let (path, _) = node_outline_at(doc, n, depth)?;
let path = if matches!(n.kind, NodeKind::Text(_)) { path } else { figcraft_doc::geometry::stroke_geometry(n).unwrap_or(path) };
let closed = figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. });
// Boolean folding uses non-zero winding; the canvas stroke cache also contains
// even-odd rectangle rings, so use the winding-correct stroke outline here.
Some(((*cached_stroke_outline(n, &path, closed)).clone(), FillRule::Winding))
}
_ => node_outline_at(doc, n, depth),
}
}