diff --git a/crates/render/src/boolean_cache_tests.rs b/crates/render/src/boolean_cache_tests.rs index 6e10f746..452626c2 100644 --- a/crates/render/src/boolean_cache_tests.rs +++ b/crates/render/src/boolean_cache_tests.rs @@ -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"); +} diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index f36c51fa..1da5e495 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -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> { + 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 { 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 { 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 { + 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 { 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), }