mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-26 19:36:46 -06:00
Merge pull request #1184 from saturngod/fix/boolean-stroke-outlines
Fix stroke-only boolean operand outlines
This commit is contained in:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user