From 6cdc36f6c3672939ee34145455e73dd17479c698 Mon Sep 17 00:00:00 2001 From: saturngod Date: Thu, 22 Oct 2026 16:03:23 -0500 Subject: [PATCH] Fix stroke-only boolean operand outlines --- crates/render/src/boolean_cache_tests.rs | 100 +++++++++++++++++++++++ crates/render/src/draw.rs | 36 +++++++- 2 files changed, 132 insertions(+), 4 deletions(-) diff --git a/crates/render/src/boolean_cache_tests.rs b/crates/render/src/boolean_cache_tests.rs index 08a89d3a..6e10f746 100644 --- a/crates/render/src/boolean_cache_tests.rs +++ b/crates/render/src/boolean_cache_tests.rs @@ -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"); +} diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 63b38220..a80cd063 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -205,6 +205,18 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc { 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 { + 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) @@ -276,7 +288,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))) }) @@ -300,6 +312,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; } @@ -308,21 +325,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), } }