#1184: boolean operands use the rounded per-side strokes too

One side_strokes helper (square or rounded, as #1044 paints them) serves stroke_area and the boolean
operand outline, instead of a second, square-only copy; test for a rounded per-side border.
This commit is contained in:
bflatastic
2026-10-26 08:35:20 +07:00
parent 57af418419
commit f124d907bc
2 changed files with 46 additions and 21 deletions
+20
View File
@@ -253,3 +253,23 @@ fn stroke_only_text_operands_use_glyphs_instead_of_the_text_box() {
text.characters = " ".into();
assert!(outline(&d, b).elements().is_empty(), "spaces have no stroked glyphs");
}
#[test]
fn rounded_side_borders_follow_their_corners_in_booleans() {
use figcraft_doc::StrokeAlign;
use figcraft_geom::Point;
let mut d = Document::new("rounded 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, NodeKind::Rectangle, "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.align = StrokeAlign::Inside;
n.stroke.sides = Some([8.0; 4]);
n.corners.radii = [30.0; 4];
let p = outline(&d, b);
assert_ne!(p.winding(Point::new(50.0, 4.0)), 0, "top border");
assert_eq!(p.winding(Point::new(3.0, 3.0)), 0, "the rounded corner leaves the square's tip out");
assert_eq!(p.winding(Point::new(50.0, 50.0)), 0, "hollow inside");
}
+26 -21
View File
@@ -156,19 +156,26 @@ pub(crate) fn hash_path(p: &BezPath, h: &mut impl Hasher) {
}
}
/// Per-side strokes on rectangles and frames (a ring under non-zero winding too), if `n` has them.
fn side_strokes(n: &Node) -> Option<Arc<BezPath>> {
let props = &n.stroke;
let sides = props.sides?;
if !matches!(n.kind, NodeKind::Rectangle | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. }) {
return None;
}
let c = &n.corners;
Some(Arc::new(if c.uniform() == Some(0.0) {
figcraft_pathops::rect_side_strokes(n.width, n.height, sides, props.align)
} else {
figcraft_pathops::rounded_side_strokes(n.width, n.height, c.radii, c.smoothing, sides, props.align)
}))
}
/// The stroke area of a node in local coordinates.
pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
let props = &n.stroke;
// Per-side strokes on rectangles/frames.
if let Some(sides) = props.sides
&& matches!(n.kind, NodeKind::Rectangle | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. })
{
let c = &n.corners;
return Arc::new(if c.uniform() == Some(0.0) {
figcraft_pathops::rect_side_strokes(n.width, n.height, sides, props.align)
} else {
figcraft_pathops::rounded_side_strokes(n.width, n.height, c.radii, c.smoothing, sides, props.align)
});
if let Some(p) = side_strokes(n) {
return p;
}
// Rect/ellipse inside/outside strokes without booleans: an even-odd ring of two outlines.
if props.dashes.is_empty() && props.profile().is_none() && props.brush.is_none() && closed && props.align != StrokeAlign::Center {
@@ -212,15 +219,15 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
cached_stroke_outline(n, path, closed)
}
/// A non-zero stroke outline, shared by painting and boolean operands.
/// The stroke a boolean operand contributes: like [`stroke_area`], but never an even-odd ring
/// (boolean folding uses non-zero winding).
fn operand_stroke(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
side_strokes(n).unwrap_or_else(|| cached_stroke_outline(n, path, closed))
}
/// A non-zero stroke outline (cached), 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)
@@ -334,7 +341,7 @@ fn subtree_outline_at(doc: &Document, n: &Node, depth: usize, stroke_only: bool)
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());
out.extend(operand_stroke(n, &p, true).elements().iter().copied());
}
}
for c in n.children.iter().filter_map(|c| doc.node(*c)) {
@@ -352,9 +359,7 @@ fn subtree_outline_at(doc: &Document, n: &Node, depth: usize, stroke_only: bool)
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))
Some(((*operand_stroke(n, &path, closed)).clone(), FillRule::Winding))
}
_ => node_outline_at(doc, n, depth),
}